@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.
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
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.2.0-beta.21",
2
+ "version": "2.2.0-beta.23",
3
3
  "name": "@apifuse/provider-sdk",
4
4
  "private": false,
5
5
  "type": "module",
@@ -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 }),
package/src/define.ts CHANGED
@@ -27,6 +27,7 @@ import type {
27
27
  OperationTransport,
28
28
  OperationWebSocketTransport,
29
29
  NativeProviderConfig,
30
+ ProviderOcrConfig,
30
31
  ProviderAccessConfig,
31
32
  ProviderDefinition,
32
33
  ProviderDeploymentOverrides,
@@ -123,6 +124,7 @@ const VALID_PROVIDER_PROXY_AFFINITIES = [
123
124
  "auth-flow",
124
125
  "connection",
125
126
  ] as const;
127
+ const VALID_PROVIDER_OCR_MODES = ["optional", "required"] as const;
126
128
  const VALID_PROVIDER_STT_MODES = ["optional", "required"] as const;
127
129
  const SMARTPROXY_APP_KEY_SECRET = "APIFUSE__PROXY__SMARTPROXY_APP_KEY";
128
130
  const NODEMAVEN_USERNAME_SECRET = "APIFUSE__PROXY__NODEMAVEN_USERNAME";
@@ -248,6 +250,7 @@ export interface ProviderConfig<TOperations extends Record<string, ProviderOpera
248
250
  platform: StealthPlatform;
249
251
  };
250
252
  proxy?: ProviderProxyConfig;
253
+ ocr?: ProviderOcrConfig;
251
254
  stt?: ProviderSttConfig;
252
255
  browser?: { engine: BrowserEngine };
253
256
  auth?: AuthConfig;
@@ -697,6 +700,18 @@ function validateProviderStt(config: { id: string; stt?: ProviderSttConfig }): v
697
700
  assertLiteralField(stt.mode, "stt.mode", VALID_PROVIDER_STT_MODES, config.id);
698
701
  }
699
702
 
703
+ function validateProviderOcr(config: { id: string; ocr?: ProviderOcrConfig }): void {
704
+ const ocr = config.ocr;
705
+ if (ocr === undefined) return;
706
+ if (!ocr || typeof ocr !== "object" || Array.isArray(ocr)) {
707
+ throw new ValidationError(`Provider "${config.id}" has invalid ocr: must be an object.`, {
708
+ fix: `Use ocr: { mode: "required" } or ocr: { mode: "optional" }.`,
709
+ });
710
+ }
711
+ rejectUnknownFields(ocr, new Set(["mode"]), "ocr");
712
+ assertLiteralField(ocr.mode, "ocr.mode", VALID_PROVIDER_OCR_MODES, config.id);
713
+ }
714
+
700
715
  function validateOperationIds(
701
716
  providerId: string,
702
717
  operations: Record<string, ProviderOperation>,
@@ -2386,6 +2401,7 @@ export function defineProvider<
2386
2401
  throw error;
2387
2402
  }
2388
2403
  validateProviderProxy(config);
2404
+ validateProviderOcr(config);
2389
2405
  validateProviderStt(config);
2390
2406
  if (config.runtime === "browser" && !config.browser)
2391
2407
  throw new ProviderError(
@@ -2410,6 +2426,7 @@ export function defineProvider<
2410
2426
  native: config.native,
2411
2427
  stealth: config.stealth,
2412
2428
  proxy: config.proxy,
2429
+ ocr: config.ocr,
2413
2430
  stt: config.stt,
2414
2431
  browser: config.browser,
2415
2432
  auth: config.auth,
@@ -1,3 +1,5 @@
1
+ import type { ProviderErrorStatus } from "./types.js";
2
+
1
3
  // This set suppresses the unregistered-provider-error-code signal for codes
2
4
  // intentionally emitted by SDK paths. It is not the complete authority for
3
5
  // runtime error resolution: branded errors and additional canonical SDK codes
@@ -28,6 +30,7 @@ export const SDK_OWNED_PROVIDER_ERROR_CODES = new Set([
28
30
  "flow_expired",
29
31
  "turn_validation_error",
30
32
  "context_access_error",
33
+ "OCR_UPSTREAM_FAILED",
31
34
  "UNSUPPORTED_STT_OPTION",
32
35
  "INVALID_STT_AUDIO",
33
36
  "STT_AUDIO_TOO_LARGE",
@@ -83,9 +86,42 @@ export const SDK_OWNED_PROVIDER_ERROR_CODES = new Set([
83
86
  export const SDK_RUNTIME_OWNED_ERROR_CODES = new Set([
84
87
  ...SDK_OWNED_PROVIDER_ERROR_CODES,
85
88
  "reauth_required",
89
+ "OCR_UNAVAILABLE",
90
+ "UNSUPPORTED_OCR_BACKEND",
86
91
  "STT_UNAVAILABLE",
87
92
  "UNSUPPORTED_STT_BACKEND",
88
93
  "OUTPUT_VALIDATION_FAILED",
89
94
  "NOT_FOUND",
90
95
  "not_found",
91
96
  ]);
97
+
98
+ // Canonical SDK status mapping for recognized provider-thrown error codes.
99
+ // serve.ts toStatusCode consults this map (after operation-declared overrides
100
+ // for non-SDK-owned codes), and the authoring lint treats these codes as
101
+ // SDK-registered. Add new codes here instead of duplicating literals in
102
+ // either consumer.
103
+ export const SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES: ReadonlyMap<string, ProviderErrorStatus> =
104
+ new Map<string, ProviderErrorStatus>([
105
+ ["AUTH_REQUIRED", 401],
106
+ ["reauth_required", 401],
107
+ // Unprovisioned declared secret: a deployment/config defect, never an
108
+ // upstream failure — explicit 400.
109
+ ["MISSING_SECRET", 400],
110
+ ["NOT_FOUND", 404],
111
+ ["not_found", 404],
112
+ ["NO_DATA", 404],
113
+ ["RATE_LIMITED", 429],
114
+ ["UPSTREAM_RATE_LIMIT", 429],
115
+ ["LIMITED_NUMBER_OF_SERVICE_REQUESTS_EXCEEDS_ERROR", 429],
116
+ // Deterministic upstream business refusal (honest-provider-error-
117
+ // contract): the upstream evaluated the request and said no under its
118
+ // own rules — a conflict with upstream state, never a 5xx.
119
+ ["UPSTREAM_REJECTED", 409],
120
+ ["UPSTREAM_ERROR", 502],
121
+ ["BLOCKED", 502],
122
+ ["OCR_UNAVAILABLE", 503],
123
+ ["UNSUPPORTED_OCR_BACKEND", 503],
124
+ ["STT_UNAVAILABLE", 503],
125
+ ["UNSUPPORTED_STT_BACKEND", 503],
126
+ ["STATEFUL_FORWARDING_REPLAY_CACHE_FULL", 503],
127
+ ]);
package/src/index.ts CHANGED
@@ -111,6 +111,22 @@ export {
111
111
  UnsupportedProviderStateError,
112
112
  } from "./runtime/state.js";
113
113
  export { createStealthClient } from "./runtime/stealth.js";
114
+ export {
115
+ APIFUSE__OCR__API_KEY_ENV,
116
+ APIFUSE__OCR__BACKEND_ENV,
117
+ APIFUSE__OCR__BASE_URL_ENV,
118
+ APIFUSE__OCR__CLOUDFLARE_API_TOKEN_ENV,
119
+ APIFUSE__OCR__MODEL_ENV,
120
+ CLOUDFLARE_ACCOUNT_ID_ENV,
121
+ CLOUDFLARE_WORKERS_AI_OCR_BACKEND,
122
+ createCloudflareWorkersAiOcrClient,
123
+ createOcrClientFromEnv,
124
+ createOpenAiCompatibleOcrClient,
125
+ createUnsupportedOcrClient,
126
+ DEFAULT_CLOUDFLARE_WORKERS_AI_OCR_MODEL,
127
+ extractCaptchaCandidates,
128
+ OPENAI_COMPATIBLE_OCR_BACKEND,
129
+ } from "./runtime/ocr.js";
114
130
  export {
115
131
  APIFUSE__STT__BACKEND_ENV,
116
132
  APIFUSE__STT__CLOUDFLARE_API_TOKEN_ENV,
@@ -229,6 +245,14 @@ export type {
229
245
  NativeTcpPortRange,
230
246
  NativeTcpTlsMode,
231
247
  NativeTlsConnectOptions,
248
+ OcrCaptchaCandidate,
249
+ OcrCaptchaOptions,
250
+ OcrCaptchaResult,
251
+ OcrContext,
252
+ OcrImageInput,
253
+ OcrRecognizeRequest,
254
+ OcrResult,
255
+ OcrWarning,
232
256
  OperationAnnotations,
233
257
  OperationApprovalPolicy,
234
258
  OperationContractMetadata,
@@ -274,6 +298,7 @@ export type {
274
298
  ProviderLogoProfile,
275
299
  ProviderLogoSource,
276
300
  ProviderMeta,
301
+ ProviderOcrConfig,
277
302
  ProviderProxyConfig,
278
303
  ProviderProxyMode,
279
304
  ProviderProxyPolicy,
package/src/lint.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  import type { ZodType } from "zod";
2
2
 
3
+ import {
4
+ SDK_RUNTIME_OWNED_ERROR_CODES,
5
+ SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES,
6
+ } from "./error-resolution.js";
3
7
  import { lintPublicSchemaFieldNames } from "./public-schema-field-lint.js";
4
8
  import { APIFUSE_DESCRIPTION_KEY_META_KEY, APIFUSE_SENSITIVE_META_KEY } from "./schema.js";
5
9
 
@@ -799,6 +803,303 @@ function lintSelfHostedBrowserPatterns(
799
803
  return diagnostics;
800
804
  }
801
805
 
806
+ const THROWN_ERROR_CONSTRUCTION_PATTERN = /new\s+(?:ProviderError|ValidationError)\s*\(/g;
807
+
808
+ const TEST_SOURCE_FILE_PATTERN = /(?:^|\/)(?:__tests__|__mocks__)\/|\.(?:test|spec)\.[cm]?[jt]sx?$/;
809
+
810
+ /**
811
+ * Skips a string literal starting at `startIndex` (which must point at the
812
+ * opening quote). Returns the index of the closing quote, or -1 when the
813
+ * literal is unterminated. Template literals handle nested `${...}`
814
+ * expressions, including strings inside them.
815
+ */
816
+ function skipStringLiteral(source: string, startIndex: number): number {
817
+ const quote = source[startIndex];
818
+ for (let index = startIndex + 1; index < source.length; index++) {
819
+ const char = source[index];
820
+ if (char === "\\") {
821
+ index++;
822
+ continue;
823
+ }
824
+ if (quote === "`" && char === "$" && source[index + 1] === "{") {
825
+ index = skipTemplateExpression(source, index + 2);
826
+ if (index < 0) {
827
+ return -1;
828
+ }
829
+ continue;
830
+ }
831
+ if (char === quote) {
832
+ return index;
833
+ }
834
+ if (quote !== "`" && char === "\n") {
835
+ return -1;
836
+ }
837
+ }
838
+ return -1;
839
+ }
840
+
841
+ function skipTemplateExpression(source: string, startIndex: number): number {
842
+ let depth = 1;
843
+ for (let index = startIndex; index < source.length; index++) {
844
+ const char = source[index];
845
+ if (char === '"' || char === "'" || char === "`") {
846
+ index = skipStringLiteral(source, index);
847
+ if (index < 0) {
848
+ return -1;
849
+ }
850
+ continue;
851
+ }
852
+ if (char === "{") {
853
+ depth++;
854
+ } else if (char === "}") {
855
+ depth--;
856
+ if (depth === 0) {
857
+ return index;
858
+ }
859
+ }
860
+ }
861
+ return -1;
862
+ }
863
+
864
+ /**
865
+ * Extracts the argument text of a call whose opening paren has already been
866
+ * consumed (`startIndex` points just past it). Returns undefined when the
867
+ * call never closes in this source, which the caller treats as "skip
868
+ * silently" — this scanner is conservative by design.
869
+ */
870
+ function extractBalancedCallArguments(source: string, startIndex: number): string | undefined {
871
+ let depth = 1;
872
+ for (let index = startIndex; index < source.length; index++) {
873
+ const char = source[index];
874
+ if (char === '"' || char === "'" || char === "`") {
875
+ index = skipStringLiteral(source, index);
876
+ if (index < 0) {
877
+ return undefined;
878
+ }
879
+ continue;
880
+ }
881
+ if (char === "/" && source[index + 1] === "/") {
882
+ const newline = source.indexOf("\n", index);
883
+ if (newline === -1) {
884
+ return undefined;
885
+ }
886
+ index = newline;
887
+ continue;
888
+ }
889
+ if (char === "/" && source[index + 1] === "*") {
890
+ const end = source.indexOf("*/", index + 2);
891
+ if (end === -1) {
892
+ return undefined;
893
+ }
894
+ index = end + 1;
895
+ continue;
896
+ }
897
+ if (char === "(") {
898
+ depth++;
899
+ } else if (char === ")") {
900
+ depth--;
901
+ if (depth === 0) {
902
+ return source.slice(startIndex, index);
903
+ }
904
+ }
905
+ }
906
+ return undefined;
907
+ }
908
+
909
+ /**
910
+ * Collects literal string values of top-level `code:` properties inside a
911
+ * ProviderError/ValidationError options object. Only plain `"..."` / `'...'`
912
+ * literals at options-object depth count; computed codes (identifiers,
913
+ * ternaries, template substitutions, concatenations, escapes) are skipped
914
+ * silently so the rule never guesses.
915
+ */
916
+ function collectLiteralErrorCodeValues(args: string): string[] {
917
+ const codes: string[] = [];
918
+ let braceDepth = 0;
919
+ let parenDepth = 0;
920
+ let bracketDepth = 0;
921
+ let previousSignificantChar = "";
922
+ for (let index = 0; index < args.length; index++) {
923
+ const char = args[index] ?? "";
924
+ if (char === '"' || char === "'" || char === "`") {
925
+ const end = skipStringLiteral(args, index);
926
+ if (end < 0) {
927
+ return codes;
928
+ }
929
+ index = end;
930
+ previousSignificantChar = char;
931
+ continue;
932
+ }
933
+ if (char === "/" && args[index + 1] === "/") {
934
+ const newline = args.indexOf("\n", index);
935
+ if (newline === -1) {
936
+ return codes;
937
+ }
938
+ index = newline;
939
+ continue;
940
+ }
941
+ if (char === "/" && args[index + 1] === "*") {
942
+ const end = args.indexOf("*/", index + 2);
943
+ if (end === -1) {
944
+ return codes;
945
+ }
946
+ index = end + 1;
947
+ continue;
948
+ }
949
+ if (/\s/.test(char)) {
950
+ continue;
951
+ }
952
+ if (char === "{") {
953
+ braceDepth++;
954
+ } else if (char === "}") {
955
+ braceDepth--;
956
+ } else if (char === "(") {
957
+ parenDepth++;
958
+ } else if (char === ")") {
959
+ parenDepth--;
960
+ } else if (char === "[") {
961
+ bracketDepth++;
962
+ } else if (char === "]") {
963
+ bracketDepth--;
964
+ } else if (
965
+ braceDepth === 1 &&
966
+ parenDepth === 0 &&
967
+ bracketDepth === 0 &&
968
+ (previousSignificantChar === "{" || previousSignificantChar === ",") &&
969
+ args.startsWith("code", index)
970
+ ) {
971
+ let cursor = index + "code".length;
972
+ while (cursor < args.length && /\s/.test(args[cursor] ?? "")) {
973
+ cursor++;
974
+ }
975
+ if (args[cursor] === ":") {
976
+ cursor++;
977
+ while (cursor < args.length && /\s/.test(args[cursor] ?? "")) {
978
+ cursor++;
979
+ }
980
+ const quote = args[cursor];
981
+ if (quote === '"' || quote === "'") {
982
+ const end = skipStringLiteral(args, cursor);
983
+ if (end > cursor) {
984
+ const value = args.slice(cursor + 1, end);
985
+ let after = end + 1;
986
+ while (after < args.length && /\s/.test(args[after] ?? "")) {
987
+ after++;
988
+ }
989
+ const nextChar = after < args.length ? (args[after] ?? "") : "";
990
+ if (!value.includes("\\") && (nextChar === "," || nextChar === "}" || nextChar === "")) {
991
+ codes.push(value);
992
+ }
993
+ index = end;
994
+ previousSignificantChar = quote;
995
+ continue;
996
+ }
997
+ return codes;
998
+ }
999
+ }
1000
+ }
1001
+ previousSignificantChar = char;
1002
+ }
1003
+ return codes;
1004
+ }
1005
+
1006
+ function collectLiteralThrownErrorCodes(source: string): string[] {
1007
+ const codes: string[] = [];
1008
+ THROWN_ERROR_CONSTRUCTION_PATTERN.lastIndex = 0;
1009
+ for (
1010
+ let match = THROWN_ERROR_CONSTRUCTION_PATTERN.exec(source);
1011
+ match;
1012
+ match = THROWN_ERROR_CONSTRUCTION_PATTERN.exec(source)
1013
+ ) {
1014
+ const argsStart = match.index + match[0].length;
1015
+ const args = extractBalancedCallArguments(source, argsStart);
1016
+ if (args !== undefined) {
1017
+ codes.push(...collectLiteralErrorCodeValues(args));
1018
+ }
1019
+ THROWN_ERROR_CONSTRUCTION_PATTERN.lastIndex = argsStart;
1020
+ }
1021
+ return codes;
1022
+ }
1023
+
1024
+ /**
1025
+ * Static counterpart of the runtime `unregistered_provider_error_code`
1026
+ * signal (honest-provider-error-contract Phase 3.5.5): flags
1027
+ * `new ProviderError(...)` / `new ValidationError(...)` constructions whose
1028
+ * literal `code` is neither SDK-registered (SDK_RUNTIME_OWNED_ERROR_CODES
1029
+ * plus the canonical status-mapped codes shared with serve.ts toStatusCode)
1030
+ * nor declared in any operation's docs.errorCodes. At runtime such a code
1031
+ * serves HTTP 500 and emits the signal; this rule surfaces it at check time.
1032
+ *
1033
+ * A throw site cannot be attributed to a specific operation statically —
1034
+ * providers routinely throw from helpers shared across operations — so this
1035
+ * rule matches against the provider-level union of declared codes. That is
1036
+ * the honest scope: it will not catch a code declared only on the "wrong"
1037
+ * operation, and it never claims per-operation attribution it cannot prove.
1038
+ * Only literal string codes are checked; computed/dynamic codes and test
1039
+ * sources are skipped silently. Warning level: the long tail of existing
1040
+ * providers converges gradually, so this must not fail `apifuse check`.
1041
+ */
1042
+ function lintUndeclaredThrownErrorCodes(provider: {
1043
+ authFlowSource?: string;
1044
+ providerSourceFiles?: Record<string, string>;
1045
+ operations?: Record<
1046
+ string,
1047
+ {
1048
+ handler?: unknown;
1049
+ source?: string;
1050
+ docs?: { errorCodes?: ReadonlyArray<{ code: string }> };
1051
+ }
1052
+ >;
1053
+ }): LintDiagnostic[] {
1054
+ const knownCodes = new Set<string>([
1055
+ ...SDK_RUNTIME_OWNED_ERROR_CODES,
1056
+ ...SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES.keys(),
1057
+ ]);
1058
+ for (const operation of Object.values(provider.operations ?? {})) {
1059
+ for (const entry of operation.docs?.errorCodes ?? []) {
1060
+ if (typeof entry?.code === "string") {
1061
+ knownCodes.add(entry.code);
1062
+ }
1063
+ }
1064
+ }
1065
+
1066
+ const sources: Array<{ field: string; source: string }> = [];
1067
+ const sourceFiles = Object.entries(provider.providerSourceFiles ?? {}).filter(
1068
+ ([filePath]) => !TEST_SOURCE_FILE_PATTERN.test(filePath),
1069
+ );
1070
+ if (sourceFiles.length > 0) {
1071
+ for (const [filePath, source] of sourceFiles) {
1072
+ sources.push({ field: `sourceFiles.${filePath}`, source });
1073
+ }
1074
+ } else {
1075
+ if (provider.authFlowSource) {
1076
+ sources.push({ field: "auth.flow", source: provider.authFlowSource });
1077
+ }
1078
+ for (const [operationKey, operation] of Object.entries(provider.operations ?? {})) {
1079
+ const source = getOperationSource(operation);
1080
+ if (source) {
1081
+ sources.push({ field: `operations.${operationKey}.handler`, source });
1082
+ }
1083
+ }
1084
+ }
1085
+
1086
+ const diagnostics: LintDiagnostic[] = [];
1087
+ for (const { field, source } of sources) {
1088
+ const undeclaredCodes = new Set(
1089
+ collectLiteralThrownErrorCodes(source).filter((code) => !knownCodes.has(code)),
1090
+ );
1091
+ for (const code of undeclaredCodes) {
1092
+ diagnostics.push({
1093
+ rule: "thrown-error-code-undeclared",
1094
+ level: "warn",
1095
+ field,
1096
+ message: `Thrown error code "${code}" (${field}) is neither SDK-registered nor declared in any operation's docs.errorCodes; at runtime it serves HTTP 500 and emits the unregistered_provider_error_code signal. Declare it in the owning operation's docs.errorCodes with status and retryable.`,
1097
+ });
1098
+ }
1099
+ }
1100
+ return diagnostics;
1101
+ }
1102
+
802
1103
  export function lintOperation(op: {
803
1104
  description?: string;
804
1105
  descriptionKey?: string;
@@ -941,6 +1242,7 @@ export function lintProvider(
941
1242
  derivations?: Record<string, string>;
942
1243
  handler?: unknown;
943
1244
  source?: string;
1245
+ docs?: { errorCodes?: ReadonlyArray<{ code: string }> };
944
1246
  }
945
1247
  >;
946
1248
  meta?: {
@@ -958,6 +1260,7 @@ export function lintProvider(
958
1260
  ...lintCredentialWriteUsage(provider),
959
1261
  ...lintPlaywrightDirectImports(provider),
960
1262
  ...lintSelfHostedBrowserPatterns(provider, options),
1263
+ ...lintUndeclaredThrownErrorCodes(provider),
961
1264
  ];
962
1265
 
963
1266
  if (provider.operations) {
@@ -5,9 +5,11 @@ import type {
5
5
  EnvContext,
6
6
  FlowContext,
7
7
  HttpClient,
8
+ OcrContext,
8
9
  StealthClient,
9
10
  SttContext,
10
11
  } from "../types.js";
12
+ import { createUnsupportedOcrClient } from "./ocr.js";
11
13
  import { createUnsupportedSttClient } from "./stt.js";
12
14
 
13
15
  function normalizeAllowedKeys(allowedKeys: string[]): Set<string> {
@@ -58,6 +60,7 @@ export function createFlowContext(options: {
58
60
  externalRef?: string;
59
61
  allowedKeys: string[];
60
62
  initialContext?: Record<string, unknown>;
63
+ ocr?: OcrContext;
61
64
  stt?: SttContext;
62
65
  }): FlowContext {
63
66
  return {
@@ -70,6 +73,7 @@ export function createFlowContext(options: {
70
73
  stealth: options.stealth,
71
74
  env: options.env,
72
75
  context: createScratchpad(options.allowedKeys, options.initialContext),
76
+ ocr: options.ocr ?? createUnsupportedOcrClient(),
73
77
  stt: options.stt ?? createUnsupportedSttClient(),
74
78
  auth: createAuthFlowHelpers(),
75
79
  };