@jhb.software/payload-alt-text-plugin 0.9.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +275 -20
  2. package/dist/endpoints/bulkGenerateAltTexts.js +21 -8
  3. package/dist/endpoints/bulkGenerateAltTexts.js.map +1 -1
  4. package/dist/endpoints/generateAltText.js +16 -5
  5. package/dist/endpoints/generateAltText.js.map +1 -1
  6. package/dist/hooks/revalidateAltTextHealth.js +31 -17
  7. package/dist/hooks/revalidateAltTextHealth.js.map +1 -1
  8. package/dist/index.d.ts +8 -1
  9. package/dist/index.js +3 -0
  10. package/dist/index.js.map +1 -1
  11. package/dist/plugin.js +43 -7
  12. package/dist/plugin.js.map +1 -1
  13. package/dist/resolvers/anthropic.d.ts +65 -0
  14. package/dist/resolvers/anthropic.js +141 -0
  15. package/dist/resolvers/anthropic.js.map +1 -0
  16. package/dist/resolvers/createVisionResolver.d.ts +148 -0
  17. package/dist/resolvers/createVisionResolver.js +300 -0
  18. package/dist/resolvers/createVisionResolver.js.map +1 -0
  19. package/dist/resolvers/mistral.d.ts +53 -0
  20. package/dist/resolvers/mistral.js +114 -0
  21. package/dist/resolvers/mistral.js.map +1 -0
  22. package/dist/resolvers/openAI.d.ts +32 -3
  23. package/dist/resolvers/openAI.js +63 -144
  24. package/dist/resolvers/openAI.js.map +1 -1
  25. package/dist/resolvers/types.d.ts +24 -1
  26. package/dist/resolvers/types.js.map +1 -1
  27. package/dist/translations/index.js.map +1 -1
  28. package/dist/types/AltTextPluginConfig.d.ts +86 -18
  29. package/dist/types/AltTextPluginConfig.js.map +1 -1
  30. package/dist/utilities/altTextHealth.d.ts +3 -1
  31. package/dist/utilities/altTextHealth.js +74 -8
  32. package/dist/utilities/altTextHealth.js.map +1 -1
  33. package/dist/utilities/mimeTypes.d.ts +54 -1
  34. package/dist/utilities/mimeTypes.js +41 -2
  35. package/dist/utilities/mimeTypes.js.map +1 -1
  36. package/dist/utilities/stableStringify.d.ts +9 -0
  37. package/dist/utilities/stableStringify.js +19 -0
  38. package/dist/utilities/stableStringify.js.map +1 -0
  39. package/package.json +14 -15
@@ -0,0 +1,148 @@
1
+ import type { PayloadRequest } from 'payload';
2
+ import { z } from 'zod';
3
+ import type { AltTextResolver } from './types.js';
4
+ export type VisionInstructionsArgs = {
5
+ /** The instructions the resolver would send on its own, stating the rules the plugin depends on */
6
+ defaultInstructions: string;
7
+ /** The uploaded file's name, when the endpoint could supply one */
8
+ filename?: string;
9
+ /** The locales the response must cover, as configured in Payload */
10
+ locales: string[];
11
+ };
12
+ export type VisionInstructions = (args: VisionInstructionsArgs) => Promise<string> | string;
13
+ /** The thumbnail's bytes, handed to providers that declared `inlineImage`. */
14
+ export type VisionImage = {
15
+ /** Base64-encoded bytes, without a data URI prefix */
16
+ base64: string;
17
+ /** `data:<mediaType>;base64,<base64>`, for providers that take a data URI */
18
+ dataUri: string;
19
+ /** The format actually served at the thumbnail URL, not the document's stored one */
20
+ mediaType: string;
21
+ };
22
+ export type VisionGenerateArgs = {
23
+ /** The uploaded file's name, when the endpoint could supply one */
24
+ filename?: string;
25
+ /** The downloaded thumbnail — present only when the resolver declared `inlineImage` */
26
+ image?: VisionImage;
27
+ /**
28
+ * The format the collection declares `getImageThumbnail` delivers, or
29
+ * undefined when nothing was declared. Resolvers that inline the bytes should
30
+ * use `image.mediaType`, which is what the URL actually served, with this
31
+ * declaration already standing in when the host named no usable type.
32
+ */
33
+ imageThumbnailMimeType?: string;
34
+ /** URL of the image thumbnail, for providers that fetch it themselves */
35
+ imageThumbnailUrl: string;
36
+ /** The instructions to send, e.g. as the system prompt */
37
+ instructions: string;
38
+ /** The locales the response must cover */
39
+ locales: string[];
40
+ /** Token budget for the response, scaled by the number of requested locales */
41
+ maxTokens: number;
42
+ req: PayloadRequest;
43
+ /** Draft-7 JSON Schema of the object the provider must return */
44
+ responseSchema: Record<string, unknown>;
45
+ /**
46
+ * Aborts once `timeoutMs` has elapsed, already covering the image download.
47
+ * Undefined when the resolver declares no `timeoutMs`, leaving the deadline to
48
+ * the provider client.
49
+ */
50
+ signal?: AbortSignal;
51
+ };
52
+ /**
53
+ * A non-ok HTTP response from a provider.
54
+ *
55
+ * Carries the status so the factory can tell a rate limit or an outage — worth
56
+ * another attempt — from a malformed request, which would fail identically every
57
+ * time.
58
+ *
59
+ * The response body is kept off `message` deliberately. That message is shown in
60
+ * the admin panel to anyone allowed to generate an alt text, while the body is
61
+ * text the provider chose: OpenAI echoes a masked form of the rejected API key
62
+ * into a 401, and providers routinely name organization ids, project ids and
63
+ * internal endpoints. It goes to the server log, where the person debugging the
64
+ * configuration is, and not to an editor's screen.
65
+ */
66
+ export declare class VisionProviderError extends Error {
67
+ /** The provider's response body, for the log only — never for `message`. */
68
+ readonly body?: string;
69
+ readonly status: number;
70
+ constructor({ body, label, status }: {
71
+ body?: string;
72
+ label: string;
73
+ status: number;
74
+ });
75
+ /** Rate limits and server-side failures are transient; a 4xx is not. */
76
+ get isTransient(): boolean;
77
+ }
78
+ export type VisionResolverConfig = {
79
+ /**
80
+ * Checked before any work happens, so a plugin wired as
81
+ * `enabled: !!process.env.X_API_KEY` fails with a readable message instead of
82
+ * a provider error — or, worse, a paid-for image download.
83
+ */
84
+ apiKey: string;
85
+ /**
86
+ * Sends one request to the provider and resolves with its parsed JSON
87
+ * response. Rejecting fails the generation, so provider errors need no
88
+ * special handling beyond throwing a readable message.
89
+ */
90
+ generate: (args: VisionGenerateArgs) => Promise<unknown>;
91
+ /**
92
+ * Download the thumbnail and hand `generate` the bytes rather than the URL.
93
+ *
94
+ * Needed by every provider whose own fetcher requires a publicly reachable
95
+ * file — never true in local development, not true for private buckets.
96
+ */
97
+ inlineImage?: boolean;
98
+ /**
99
+ * Builds the instructions from the default ones, e.g. to append a house style
100
+ * rule. Called once per generation. The image and the required response shape
101
+ * are not part of the instructions and cannot be altered here.
102
+ *
103
+ * @default ({ defaultInstructions }) => defaultInstructions
104
+ */
105
+ instructions?: VisionInstructions;
106
+ /** Identifies the resolver, e.g. in log entries */
107
+ key: string;
108
+ /** Provider name used in error messages shown in the admin UI */
109
+ label: string;
110
+ /**
111
+ * Rejects an inlined image above this size before it is sent.
112
+ * @default 20971520 (20 MB)
113
+ */
114
+ maxImageBytes?: number;
115
+ /**
116
+ * Token budget granted per requested locale. A ceiling, not a reservation, so
117
+ * headroom is free; the default keeps the pre-factory bulk budget of 300 for
118
+ * every locale count rather than only for two or more.
119
+ * @default 300
120
+ */
121
+ maxTokensPerLocale?: number;
122
+ /** @see AltTextResolver.supportedMimeTypes */
123
+ supportedMimeTypes?: string[];
124
+ /**
125
+ * Abort after this many milliseconds, covering the image download and the
126
+ * provider call together. Omit it to impose no deadline of the factory's own —
127
+ * appropriate when the provider's own client already has one.
128
+ */
129
+ timeoutMs?: number;
130
+ };
131
+ /** One schema entry per requested locale, so the model must answer for all of them. */
132
+ export declare const schemaForLocales: (locales: string[]) => z.ZodObject<{
133
+ [x: string]: z.ZodObject<{
134
+ altText: z.ZodString;
135
+ keywords: z.ZodArray<z.ZodString>;
136
+ }, z.core.$strip>;
137
+ }, z.core.$strip>;
138
+ /**
139
+ * Creates a resolver for a vision (LLM) provider, leaving only the provider call
140
+ * to `generate`: the prompt, the required response schema, the optional image
141
+ * download and the strict reading of the response are handled here.
142
+ *
143
+ * All locales go into a single call rather than one call each: the image is
144
+ * uploaded and analyzed once — the expensive part — and every language ends up
145
+ * describing the same reading of it. `resolve` is that same call with one
146
+ * locale.
147
+ */
148
+ export declare const createVisionResolver: ({ apiKey, generate, inlineImage, instructions, key, label, maxImageBytes, maxTokensPerLocale, supportedMimeTypes, timeoutMs, }: VisionResolverConfig) => AltTextResolver;
@@ -0,0 +1,300 @@
1
+ import { z } from 'zod';
2
+ /**
3
+ * A non-ok HTTP response from a provider.
4
+ *
5
+ * Carries the status so the factory can tell a rate limit or an outage — worth
6
+ * another attempt — from a malformed request, which would fail identically every
7
+ * time.
8
+ *
9
+ * The response body is kept off `message` deliberately. That message is shown in
10
+ * the admin panel to anyone allowed to generate an alt text, while the body is
11
+ * text the provider chose: OpenAI echoes a masked form of the rejected API key
12
+ * into a 401, and providers routinely name organization ids, project ids and
13
+ * internal endpoints. It goes to the server log, where the person debugging the
14
+ * configuration is, and not to an editor's screen.
15
+ */ export class VisionProviderError extends Error {
16
+ /** The provider's response body, for the log only — never for `message`. */ body;
17
+ status;
18
+ constructor({ body, label, status }){
19
+ super(`${label} responded with status ${status}`);
20
+ this.body = body;
21
+ this.name = 'VisionProviderError';
22
+ this.status = status;
23
+ }
24
+ /** Rate limits and server-side failures are transient; a 4xx is not. */ get isTransient() {
25
+ return this.status === 429 || this.status >= 500;
26
+ }
27
+ }
28
+ /** Attempts after the first, for a provider error that may pass on a retry. */ const MAX_RETRIES = 2;
29
+ /** Backs off between attempts, bounded by the resolver's own deadline. */ const retryDelayMs = (attempt)=>250 * 2 ** (attempt - 1);
30
+ const altTextSchema = z.object({
31
+ altText: z.string().describe('A concise, descriptive alt text for the image'),
32
+ keywords: z.array(z.string()).describe('Keywords that describe the content of the image')
33
+ });
34
+ /** One schema entry per requested locale, so the model must answer for all of them. */ export const schemaForLocales = (locales)=>z.object(Object.fromEntries(locales.map((locale)=>[
35
+ locale,
36
+ altTextSchema
37
+ ])));
38
+ /**
39
+ * Rules dictated by the plugin rather than by the provider: one entry per
40
+ * configured locale, describing what is visible rather than guessing at it.
41
+ */ const buildDefaultInstructions = ({ locales })=>[
42
+ `You are an expert at analyzing images and creating descriptive image alt text.`,
43
+ `Please analyze the given image and provide the following in ${locales.join(', ')}:`,
44
+ `- A concise, localized descriptive alt text (1-2 sentences) as "altText". Focus on the subject, action, and setting. Avoid phrases like 'Image of', 'A picture of', or 'Photo showing'. Be specific and include relevant details like location or context if visible. Make no assumptions.`,
45
+ `- A localized list of keywords that describe the content (e.g., ["Camel", "Palm trees", "Desert"]) as "keywords"`,
46
+ `If a context is provided, use it to enhance the alt text.`,
47
+ `Format your response as a JSON object with ${locales.map((locale)=>`"${locale}"`).join(', ')} keys, each containing "altText" and "keywords".`
48
+ ].join('\n\n');
49
+ /**
50
+ * Downloads the image and returns its bytes.
51
+ *
52
+ * The document's mime type is checked by the endpoint before the resolver runs,
53
+ * but `getImageThumbnail` may point at a derivative in a different format, so
54
+ * what was actually served is what counts. Only when the host names no usable
55
+ * type at all — no header, or a generic `application/octet-stream` as private
56
+ * buckets and signed URLs often send — does the collection's declared
57
+ * `imageThumbnailMimeType` stand in for it.
58
+ */ async function fetchImage({ declaredMediaType, label, maxImageBytes, signal, supportedMimeTypes, url }) {
59
+ let response;
60
+ try {
61
+ response = await fetch(url, {
62
+ signal
63
+ });
64
+ } catch (error) {
65
+ return {
66
+ error: `Could not download the image from ${url}: ${error instanceof Error ? error.message : 'unknown error'}`
67
+ };
68
+ }
69
+ if (!response.ok) {
70
+ return {
71
+ error: `Could not download the image from ${url}: status ${response.status}`
72
+ };
73
+ }
74
+ const served = response.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase();
75
+ const mediaType = served && served !== 'application/octet-stream' ? served : declaredMediaType?.toLowerCase();
76
+ if (!mediaType) {
77
+ return {
78
+ error: `The image at ${url} was served as ${served ? `"${served}"` : 'no content type at all'}, which does not name an image format. Declare imageThumbnailMimeType for the collection so ${label} knows what it is reading.`
79
+ };
80
+ }
81
+ if (supportedMimeTypes && !supportedMimeTypes.includes(mediaType)) {
82
+ return {
83
+ error: `The image at ${url} was served as "${mediaType}", which ${label} cannot read. Supported types: ${supportedMimeTypes.join(', ')}.`
84
+ };
85
+ }
86
+ const tooLarge = (byteLength)=>`The image at ${url} is ${Math.round(byteLength / 1024 / 1024)} MB, above ${label}'s ${Math.round(maxImageBytes / 1024 / 1024)} MB limit. Point getImageThumbnail at a smaller image size.`;
87
+ // Measuring by reading is work the header already answers, and the file is on
88
+ // its way to being rejected: `getImageThumbnail` may point at the original
89
+ // upload, which can be far above the provider's limit.
90
+ const declaredLength = Number(response.headers.get('content-length'));
91
+ if (Number.isInteger(declaredLength) && declaredLength > maxImageBytes) {
92
+ return {
93
+ error: tooLarge(declaredLength)
94
+ };
95
+ }
96
+ const bytes = Buffer.from(await response.arrayBuffer());
97
+ if (bytes.byteLength === 0) {
98
+ return {
99
+ error: `The image at ${url} was empty.`
100
+ };
101
+ }
102
+ if (bytes.byteLength > maxImageBytes) {
103
+ return {
104
+ error: tooLarge(bytes.byteLength)
105
+ };
106
+ }
107
+ const base64 = bytes.toString('base64');
108
+ return {
109
+ image: {
110
+ base64,
111
+ dataUri: `data:${mediaType};base64,${base64}`,
112
+ mediaType
113
+ }
114
+ };
115
+ }
116
+ /**
117
+ * Runs the provider call, retrying a transient failure.
118
+ *
119
+ * Every bundled resolver reaches its provider over `fetch`, which retries
120
+ * nothing by itself. Without this, a bulk generation that trips a rate limit
121
+ * gives up on the first 429 and leaves those images without an alt text. The
122
+ * resolver's `timeoutMs` covers the attempts together, so a deadline still
123
+ * bounds the whole call.
124
+ */ async function generateWithRetry({ args, generate }) {
125
+ for(let attempt = 0;; attempt++){
126
+ try {
127
+ return await generate(args);
128
+ } catch (error) {
129
+ const isRetryable = error instanceof VisionProviderError && error.isTransient;
130
+ if (!isRetryable || attempt >= MAX_RETRIES || args.signal?.aborted) {
131
+ throw error;
132
+ }
133
+ await new Promise((resolve)=>setTimeout(resolve, retryDelayMs(attempt + 1)));
134
+ }
135
+ }
136
+ }
137
+ /**
138
+ * Reads the model's response.
139
+ *
140
+ * Deliberately strict about a blank `altText`: the field is required on the
141
+ * collection, so an empty string would satisfy that requirement while telling a
142
+ * screen reader nothing — and nobody looks at an alt text again once it is set.
143
+ */ function parseResults(content, locales) {
144
+ const parsed = schemaForLocales(locales).safeParse(content);
145
+ if (!parsed.success) {
146
+ return null;
147
+ }
148
+ const results = {};
149
+ for (const locale of locales){
150
+ const entry = parsed.data[locale];
151
+ if (entry.altText.trim().length === 0) {
152
+ return null;
153
+ }
154
+ results[locale] = {
155
+ altText: entry.altText.trim(),
156
+ keywords: entry.keywords
157
+ };
158
+ }
159
+ return results;
160
+ }
161
+ /**
162
+ * Creates a resolver for a vision (LLM) provider, leaving only the provider call
163
+ * to `generate`: the prompt, the required response schema, the optional image
164
+ * download and the strict reading of the response are handled here.
165
+ *
166
+ * All locales go into a single call rather than one call each: the image is
167
+ * uploaded and analyzed once — the expensive part — and every language ends up
168
+ * describing the same reading of it. `resolve` is that same call with one
169
+ * locale.
170
+ */ export const createVisionResolver = ({ apiKey, generate, inlineImage = false, instructions = ({ defaultInstructions })=>defaultInstructions, key, label, maxImageBytes = 20 * 1024 * 1024, maxTokensPerLocale = 300, supportedMimeTypes, timeoutMs })=>{
171
+ const run = async ({ filename, imageThumbnailMimeType, imageThumbnailUrl, locales, req })=>{
172
+ if (!apiKey) {
173
+ return {
174
+ error: `No ${label} API key configured`,
175
+ success: false
176
+ };
177
+ }
178
+ if (locales.length === 0) {
179
+ return {
180
+ error: 'No locale requested',
181
+ success: false
182
+ };
183
+ }
184
+ const signal = timeoutMs === undefined ? undefined : AbortSignal.timeout(timeoutMs);
185
+ let image;
186
+ if (inlineImage) {
187
+ const downloaded = await fetchImage({
188
+ declaredMediaType: imageThumbnailMimeType,
189
+ label,
190
+ maxImageBytes,
191
+ signal,
192
+ supportedMimeTypes,
193
+ url: imageThumbnailUrl
194
+ });
195
+ if ('error' in downloaded) {
196
+ return {
197
+ error: downloaded.error,
198
+ success: false
199
+ };
200
+ }
201
+ image = downloaded.image;
202
+ }
203
+ try {
204
+ const defaultInstructions = buildDefaultInstructions({
205
+ locales
206
+ });
207
+ const content = await generateWithRetry({
208
+ args: {
209
+ filename,
210
+ image,
211
+ imageThumbnailMimeType,
212
+ imageThumbnailUrl,
213
+ instructions: await instructions({
214
+ defaultInstructions,
215
+ filename,
216
+ locales
217
+ }),
218
+ locales,
219
+ maxTokens: maxTokensPerLocale * locales.length,
220
+ req,
221
+ responseSchema: z.toJSONSchema(schemaForLocales(locales), {
222
+ target: 'draft-7'
223
+ }),
224
+ signal
225
+ },
226
+ generate
227
+ });
228
+ const results = parseResults(content, locales);
229
+ if (!results) {
230
+ return {
231
+ error: `${label} did not return a usable alt text for every requested locale (${locales.join(', ')})`,
232
+ success: false
233
+ };
234
+ }
235
+ return {
236
+ results,
237
+ success: true
238
+ };
239
+ } catch (error) {
240
+ req.payload.logger.error({
241
+ err: error,
242
+ msg: 'Error generating alt text',
243
+ // Logged separately: it is deliberately absent from the error message
244
+ // the admin panel shows, and is what a misconfiguration is diagnosed from.
245
+ providerResponse: error instanceof VisionProviderError ? error.body : undefined,
246
+ resolver: key
247
+ });
248
+ return {
249
+ error: error instanceof Error ? error.message : 'Unknown error',
250
+ success: false
251
+ };
252
+ }
253
+ };
254
+ return {
255
+ key,
256
+ resolve: async ({ filename, imageThumbnailMimeType, imageThumbnailUrl, locale, req })=>{
257
+ const result = await run({
258
+ filename,
259
+ imageThumbnailMimeType,
260
+ imageThumbnailUrl,
261
+ locales: [
262
+ locale
263
+ ],
264
+ req
265
+ });
266
+ if (!result.success) {
267
+ return {
268
+ error: result.error,
269
+ success: false
270
+ };
271
+ }
272
+ return {
273
+ result: result.results[locale],
274
+ success: true
275
+ };
276
+ },
277
+ resolveBulk: async ({ filename, imageThumbnailMimeType, imageThumbnailUrl, locales, req })=>{
278
+ const result = await run({
279
+ filename,
280
+ imageThumbnailMimeType,
281
+ imageThumbnailUrl,
282
+ locales,
283
+ req
284
+ });
285
+ if (!result.success) {
286
+ return {
287
+ error: result.error,
288
+ success: false
289
+ };
290
+ }
291
+ return {
292
+ results: result.results,
293
+ success: true
294
+ };
295
+ },
296
+ supportedMimeTypes
297
+ };
298
+ };
299
+
300
+ //# sourceMappingURL=createVisionResolver.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/resolvers/createVisionResolver.ts"],"sourcesContent":["import type { PayloadRequest } from 'payload'\n\nimport { z } from 'zod'\n\nimport type {\n AltTextBulkResolverArgs,\n AltTextBulkResolverResponse,\n AltTextResolver,\n AltTextResolverArgs,\n AltTextResolverResponse,\n AltTextResult,\n} from './types.js'\n\nexport type VisionInstructionsArgs = {\n /** The instructions the resolver would send on its own, stating the rules the plugin depends on */\n defaultInstructions: string\n /** The uploaded file's name, when the endpoint could supply one */\n filename?: string\n /** The locales the response must cover, as configured in Payload */\n locales: string[]\n}\n\nexport type VisionInstructions = (args: VisionInstructionsArgs) => Promise<string> | string\n\n/** The thumbnail's bytes, handed to providers that declared `inlineImage`. */\nexport type VisionImage = {\n /** Base64-encoded bytes, without a data URI prefix */\n base64: string\n /** `data:<mediaType>;base64,<base64>`, for providers that take a data URI */\n dataUri: string\n /** The format actually served at the thumbnail URL, not the document's stored one */\n mediaType: string\n}\n\nexport type VisionGenerateArgs = {\n /** The uploaded file's name, when the endpoint could supply one */\n filename?: string\n /** The downloaded thumbnail — present only when the resolver declared `inlineImage` */\n image?: VisionImage\n /**\n * The format the collection declares `getImageThumbnail` delivers, or\n * undefined when nothing was declared. Resolvers that inline the bytes should\n * use `image.mediaType`, which is what the URL actually served, with this\n * declaration already standing in when the host named no usable type.\n */\n imageThumbnailMimeType?: string\n /** URL of the image thumbnail, for providers that fetch it themselves */\n imageThumbnailUrl: string\n /** The instructions to send, e.g. as the system prompt */\n instructions: string\n /** The locales the response must cover */\n locales: string[]\n /** Token budget for the response, scaled by the number of requested locales */\n maxTokens: number\n req: PayloadRequest\n /** Draft-7 JSON Schema of the object the provider must return */\n responseSchema: Record<string, unknown>\n /**\n * Aborts once `timeoutMs` has elapsed, already covering the image download.\n * Undefined when the resolver declares no `timeoutMs`, leaving the deadline to\n * the provider client.\n */\n signal?: AbortSignal\n}\n\n/**\n * A non-ok HTTP response from a provider.\n *\n * Carries the status so the factory can tell a rate limit or an outage — worth\n * another attempt — from a malformed request, which would fail identically every\n * time.\n *\n * The response body is kept off `message` deliberately. That message is shown in\n * the admin panel to anyone allowed to generate an alt text, while the body is\n * text the provider chose: OpenAI echoes a masked form of the rejected API key\n * into a 401, and providers routinely name organization ids, project ids and\n * internal endpoints. It goes to the server log, where the person debugging the\n * configuration is, and not to an editor's screen.\n */\nexport class VisionProviderError extends Error {\n /** The provider's response body, for the log only — never for `message`. */\n readonly body?: string\n readonly status: number\n\n constructor({ body, label, status }: { body?: string; label: string; status: number }) {\n super(`${label} responded with status ${status}`)\n this.body = body\n this.name = 'VisionProviderError'\n this.status = status\n }\n\n /** Rate limits and server-side failures are transient; a 4xx is not. */\n get isTransient(): boolean {\n return this.status === 429 || this.status >= 500\n }\n}\n\n/** Attempts after the first, for a provider error that may pass on a retry. */\nconst MAX_RETRIES = 2\n\n/** Backs off between attempts, bounded by the resolver's own deadline. */\nconst retryDelayMs = (attempt: number) => 250 * 2 ** (attempt - 1)\n\nexport type VisionResolverConfig = {\n /**\n * Checked before any work happens, so a plugin wired as\n * `enabled: !!process.env.X_API_KEY` fails with a readable message instead of\n * a provider error — or, worse, a paid-for image download.\n */\n apiKey: string\n /**\n * Sends one request to the provider and resolves with its parsed JSON\n * response. Rejecting fails the generation, so provider errors need no\n * special handling beyond throwing a readable message.\n */\n generate: (args: VisionGenerateArgs) => Promise<unknown>\n /**\n * Download the thumbnail and hand `generate` the bytes rather than the URL.\n *\n * Needed by every provider whose own fetcher requires a publicly reachable\n * file — never true in local development, not true for private buckets.\n */\n inlineImage?: boolean\n /**\n * Builds the instructions from the default ones, e.g. to append a house style\n * rule. Called once per generation. The image and the required response shape\n * are not part of the instructions and cannot be altered here.\n *\n * @default ({ defaultInstructions }) => defaultInstructions\n */\n instructions?: VisionInstructions\n /** Identifies the resolver, e.g. in log entries */\n key: string\n /** Provider name used in error messages shown in the admin UI */\n label: string\n /**\n * Rejects an inlined image above this size before it is sent.\n * @default 20971520 (20 MB)\n */\n maxImageBytes?: number\n /**\n * Token budget granted per requested locale. A ceiling, not a reservation, so\n * headroom is free; the default keeps the pre-factory bulk budget of 300 for\n * every locale count rather than only for two or more.\n * @default 300\n */\n maxTokensPerLocale?: number\n /** @see AltTextResolver.supportedMimeTypes */\n supportedMimeTypes?: string[]\n /**\n * Abort after this many milliseconds, covering the image download and the\n * provider call together. Omit it to impose no deadline of the factory's own —\n * appropriate when the provider's own client already has one.\n */\n timeoutMs?: number\n}\n\nconst altTextSchema = z.object({\n altText: z.string().describe('A concise, descriptive alt text for the image'),\n keywords: z.array(z.string()).describe('Keywords that describe the content of the image'),\n})\n\n/** One schema entry per requested locale, so the model must answer for all of them. */\nexport const schemaForLocales = (locales: string[]) =>\n z.object(Object.fromEntries(locales.map((locale) => [locale, altTextSchema])))\n\n/**\n * Rules dictated by the plugin rather than by the provider: one entry per\n * configured locale, describing what is visible rather than guessing at it.\n */\nconst buildDefaultInstructions = ({ locales }: { locales: string[] }): string =>\n [\n `You are an expert at analyzing images and creating descriptive image alt text.`,\n\n `Please analyze the given image and provide the following in ${locales.join(', ')}:`,\n\n `- A concise, localized descriptive alt text (1-2 sentences) as \"altText\". Focus on the subject, action, and setting. Avoid phrases like 'Image of', 'A picture of', or 'Photo showing'. Be specific and include relevant details like location or context if visible. Make no assumptions.`,\n\n `- A localized list of keywords that describe the content (e.g., [\"Camel\", \"Palm trees\", \"Desert\"]) as \"keywords\"`,\n\n `If a context is provided, use it to enhance the alt text.`,\n\n `Format your response as a JSON object with ${locales.map((locale) => `\"${locale}\"`).join(', ')} keys, each containing \"altText\" and \"keywords\".`,\n ].join('\\n\\n')\n\n/**\n * Downloads the image and returns its bytes.\n *\n * The document's mime type is checked by the endpoint before the resolver runs,\n * but `getImageThumbnail` may point at a derivative in a different format, so\n * what was actually served is what counts. Only when the host names no usable\n * type at all — no header, or a generic `application/octet-stream` as private\n * buckets and signed URLs often send — does the collection's declared\n * `imageThumbnailMimeType` stand in for it.\n */\nasync function fetchImage({\n declaredMediaType,\n label,\n maxImageBytes,\n signal,\n supportedMimeTypes,\n url,\n}: {\n declaredMediaType?: string\n label: string\n maxImageBytes: number\n signal?: AbortSignal\n supportedMimeTypes?: string[]\n url: string\n}): Promise<{ error: string } | { image: VisionImage }> {\n let response: Response\n\n try {\n response = await fetch(url, { signal })\n } catch (error) {\n return {\n error: `Could not download the image from ${url}: ${error instanceof Error ? error.message : 'unknown error'}`,\n }\n }\n\n if (!response.ok) {\n return { error: `Could not download the image from ${url}: status ${response.status}` }\n }\n\n const served = response.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase()\n const mediaType =\n served && served !== 'application/octet-stream' ? served : declaredMediaType?.toLowerCase()\n\n if (!mediaType) {\n return {\n error: `The image at ${url} was served as ${served ? `\"${served}\"` : 'no content type at all'}, which does not name an image format. Declare imageThumbnailMimeType for the collection so ${label} knows what it is reading.`,\n }\n }\n\n if (supportedMimeTypes && !supportedMimeTypes.includes(mediaType)) {\n return {\n error: `The image at ${url} was served as \"${mediaType}\", which ${label} cannot read. Supported types: ${supportedMimeTypes.join(', ')}.`,\n }\n }\n\n const tooLarge = (byteLength: number) =>\n `The image at ${url} is ${Math.round(byteLength / 1024 / 1024)} MB, above ${label}'s ${Math.round(maxImageBytes / 1024 / 1024)} MB limit. Point getImageThumbnail at a smaller image size.`\n\n // Measuring by reading is work the header already answers, and the file is on\n // its way to being rejected: `getImageThumbnail` may point at the original\n // upload, which can be far above the provider's limit.\n const declaredLength = Number(response.headers.get('content-length'))\n\n if (Number.isInteger(declaredLength) && declaredLength > maxImageBytes) {\n return { error: tooLarge(declaredLength) }\n }\n\n const bytes = Buffer.from(await response.arrayBuffer())\n\n if (bytes.byteLength === 0) {\n return { error: `The image at ${url} was empty.` }\n }\n\n if (bytes.byteLength > maxImageBytes) {\n return { error: tooLarge(bytes.byteLength) }\n }\n\n const base64 = bytes.toString('base64')\n\n return { image: { base64, dataUri: `data:${mediaType};base64,${base64}`, mediaType } }\n}\n\n/**\n * Runs the provider call, retrying a transient failure.\n *\n * Every bundled resolver reaches its provider over `fetch`, which retries\n * nothing by itself. Without this, a bulk generation that trips a rate limit\n * gives up on the first 429 and leaves those images without an alt text. The\n * resolver's `timeoutMs` covers the attempts together, so a deadline still\n * bounds the whole call.\n */\nasync function generateWithRetry({\n args,\n generate,\n}: {\n args: VisionGenerateArgs\n generate: (args: VisionGenerateArgs) => Promise<unknown>\n}): Promise<unknown> {\n for (let attempt = 0; ; attempt++) {\n try {\n return await generate(args)\n } catch (error) {\n const isRetryable = error instanceof VisionProviderError && error.isTransient\n\n if (!isRetryable || attempt >= MAX_RETRIES || args.signal?.aborted) {\n throw error\n }\n\n await new Promise((resolve) => setTimeout(resolve, retryDelayMs(attempt + 1)))\n }\n }\n}\n\n/**\n * Reads the model's response.\n *\n * Deliberately strict about a blank `altText`: the field is required on the\n * collection, so an empty string would satisfy that requirement while telling a\n * screen reader nothing — and nobody looks at an alt text again once it is set.\n */\nfunction parseResults(content: unknown, locales: string[]): null | Record<string, AltTextResult> {\n const parsed = schemaForLocales(locales).safeParse(content)\n\n if (!parsed.success) {\n return null\n }\n\n const results: Record<string, AltTextResult> = {}\n\n for (const locale of locales) {\n const entry = parsed.data[locale]\n\n if (entry.altText.trim().length === 0) {\n return null\n }\n\n results[locale] = { altText: entry.altText.trim(), keywords: entry.keywords }\n }\n\n return results\n}\n\n/**\n * Creates a resolver for a vision (LLM) provider, leaving only the provider call\n * to `generate`: the prompt, the required response schema, the optional image\n * download and the strict reading of the response are handled here.\n *\n * All locales go into a single call rather than one call each: the image is\n * uploaded and analyzed once — the expensive part — and every language ends up\n * describing the same reading of it. `resolve` is that same call with one\n * locale.\n */\nexport const createVisionResolver = ({\n apiKey,\n generate,\n inlineImage = false,\n instructions = ({ defaultInstructions }) => defaultInstructions,\n key,\n label,\n maxImageBytes = 20 * 1024 * 1024,\n maxTokensPerLocale = 300,\n supportedMimeTypes,\n timeoutMs,\n}: VisionResolverConfig): AltTextResolver => {\n const run = async ({\n filename,\n imageThumbnailMimeType,\n imageThumbnailUrl,\n locales,\n req,\n }: {\n filename?: string\n imageThumbnailMimeType?: string\n imageThumbnailUrl: string\n locales: string[]\n req: PayloadRequest\n }): Promise<\n { error: string; success: false } | { results: Record<string, AltTextResult>; success: true }\n > => {\n if (!apiKey) {\n return { error: `No ${label} API key configured`, success: false }\n }\n\n if (locales.length === 0) {\n return { error: 'No locale requested', success: false }\n }\n\n const signal = timeoutMs === undefined ? undefined : AbortSignal.timeout(timeoutMs)\n\n let image: undefined | VisionImage\n\n if (inlineImage) {\n const downloaded = await fetchImage({\n declaredMediaType: imageThumbnailMimeType,\n label,\n maxImageBytes,\n signal,\n supportedMimeTypes,\n url: imageThumbnailUrl,\n })\n\n if ('error' in downloaded) {\n return { error: downloaded.error, success: false }\n }\n\n image = downloaded.image\n }\n\n try {\n const defaultInstructions = buildDefaultInstructions({ locales })\n\n const content = await generateWithRetry({\n args: {\n filename,\n image,\n imageThumbnailMimeType,\n imageThumbnailUrl,\n instructions: await instructions({ defaultInstructions, filename, locales }),\n locales,\n maxTokens: maxTokensPerLocale * locales.length,\n req,\n responseSchema: z.toJSONSchema(schemaForLocales(locales), { target: 'draft-7' }),\n signal,\n },\n generate,\n })\n\n const results = parseResults(content, locales)\n\n if (!results) {\n return {\n error: `${label} did not return a usable alt text for every requested locale (${locales.join(', ')})`,\n success: false,\n }\n }\n\n return { results, success: true }\n } catch (error) {\n req.payload.logger.error({\n err: error,\n msg: 'Error generating alt text',\n // Logged separately: it is deliberately absent from the error message\n // the admin panel shows, and is what a misconfiguration is diagnosed from.\n providerResponse: error instanceof VisionProviderError ? error.body : undefined,\n resolver: key,\n })\n\n return { error: error instanceof Error ? error.message : 'Unknown error', success: false }\n }\n }\n\n return {\n key,\n resolve: async ({\n filename,\n imageThumbnailMimeType,\n imageThumbnailUrl,\n locale,\n req,\n }: AltTextResolverArgs): Promise<AltTextResolverResponse> => {\n const result = await run({\n filename,\n imageThumbnailMimeType,\n imageThumbnailUrl,\n locales: [locale],\n req,\n })\n\n if (!result.success) {\n return { error: result.error, success: false }\n }\n\n return { result: result.results[locale], success: true }\n },\n resolveBulk: async ({\n filename,\n imageThumbnailMimeType,\n imageThumbnailUrl,\n locales,\n req,\n }: AltTextBulkResolverArgs): Promise<AltTextBulkResolverResponse> => {\n const result = await run({\n filename,\n imageThumbnailMimeType,\n imageThumbnailUrl,\n locales,\n req,\n })\n\n if (!result.success) {\n return { error: result.error, success: false }\n }\n\n return { results: result.results, success: true }\n },\n supportedMimeTypes,\n }\n}\n"],"names":["z","VisionProviderError","Error","body","status","label","name","isTransient","MAX_RETRIES","retryDelayMs","attempt","altTextSchema","object","altText","string","describe","keywords","array","schemaForLocales","locales","Object","fromEntries","map","locale","buildDefaultInstructions","join","fetchImage","declaredMediaType","maxImageBytes","signal","supportedMimeTypes","url","response","fetch","error","message","ok","served","headers","get","split","trim","toLowerCase","mediaType","includes","tooLarge","byteLength","Math","round","declaredLength","Number","isInteger","bytes","Buffer","from","arrayBuffer","base64","toString","image","dataUri","generateWithRetry","args","generate","isRetryable","aborted","Promise","resolve","setTimeout","parseResults","content","parsed","safeParse","success","results","entry","data","length","createVisionResolver","apiKey","inlineImage","instructions","defaultInstructions","key","maxTokensPerLocale","timeoutMs","run","filename","imageThumbnailMimeType","imageThumbnailUrl","req","undefined","AbortSignal","timeout","downloaded","maxTokens","responseSchema","toJSONSchema","target","payload","logger","err","msg","providerResponse","resolver","result","resolveBulk"],"mappings":"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"}
@@ -0,0 +1,53 @@
1
+ import type { VisionInstructions } from './createVisionResolver.js';
2
+ import type { AltTextResolver } from './types.js';
3
+ export type MistralResolverConfig = {
4
+ /** Mistral API key for authentication */
5
+ apiKey: string;
6
+ /**
7
+ * Base URL of the Mistral API.
8
+ * @default 'https://api.mistral.ai/v1'
9
+ */
10
+ baseUrl?: string;
11
+ /**
12
+ * Builds the instructions from the default ones, e.g. to append a house style
13
+ * rule. Sent as the system message, separately from the image.
14
+ *
15
+ * @default ({ defaultInstructions }) => defaultInstructions
16
+ */
17
+ instructions?: VisionInstructions;
18
+ /**
19
+ * The vision-capable Mistral model to use for alt text generation.
20
+ *
21
+ * Must be able to read images — `mistral-medium-latest`,
22
+ * `mistral-large-latest`, `mistral-small-latest` and the `ministral-*` models
23
+ * all are.
24
+ *
25
+ * @default 'mistral-medium-latest'
26
+ */
27
+ model?: string;
28
+ /**
29
+ * Abort after this many milliseconds. Covers downloading the image and the
30
+ * completion call together.
31
+ * @default 30000
32
+ */
33
+ timeoutMs?: number;
34
+ };
35
+ /**
36
+ * Creates a Mistral-based resolver for alt text generation.
37
+ *
38
+ * The image is downloaded and sent as bytes rather than handed to Mistral as a
39
+ * URL. Mistral's own fetcher requires a publicly reachable file — never true in
40
+ * local development, and not true for private buckets — and some hosts refuse it
41
+ * outright, which surfaces as `File could not be fetched from url` (error 3310).
42
+ *
43
+ * @example
44
+ * ```typescript
45
+ * import { mistralResolver } from '@jhb.software/payload-alt-text-plugin'
46
+ *
47
+ * mistralResolver({
48
+ * apiKey: process.env.MISTRAL_API_KEY,
49
+ * model: 'mistral-medium-latest', // optional, this is the default
50
+ * })
51
+ * ```
52
+ */
53
+ export declare const mistralResolver: ({ apiKey, baseUrl, instructions, model, timeoutMs, }: MistralResolverConfig) => AltTextResolver;
@@ -0,0 +1,114 @@
1
+ import { createVisionResolver, VisionProviderError } from './createVisionResolver.js';
2
+ /**
3
+ * Image formats the Mistral API accepts.
4
+ *
5
+ * Narrower than what an upload collection may hold — SVG and AVIF are missing,
6
+ * so the endpoint rejects those documents and their generate button stays
7
+ * disabled instead of failing at the provider.
8
+ *
9
+ * @see https://docs.mistral.ai/capabilities/vision/
10
+ */ const SUPPORTED_MIME_TYPES = [
11
+ 'image/jpeg',
12
+ 'image/png',
13
+ 'image/gif',
14
+ 'image/webp'
15
+ ];
16
+ /**
17
+ * Creates a Mistral-based resolver for alt text generation.
18
+ *
19
+ * The image is downloaded and sent as bytes rather than handed to Mistral as a
20
+ * URL. Mistral's own fetcher requires a publicly reachable file — never true in
21
+ * local development, and not true for private buckets — and some hosts refuse it
22
+ * outright, which surfaces as `File could not be fetched from url` (error 3310).
23
+ *
24
+ * @example
25
+ * ```typescript
26
+ * import { mistralResolver } from '@jhb.software/payload-alt-text-plugin'
27
+ *
28
+ * mistralResolver({
29
+ * apiKey: process.env.MISTRAL_API_KEY,
30
+ * model: 'mistral-medium-latest', // optional, this is the default
31
+ * })
32
+ * ```
33
+ */ export const mistralResolver = ({ apiKey, baseUrl = 'https://api.mistral.ai/v1', instructions, model = 'mistral-medium-latest', timeoutMs = 30_000 })=>createVisionResolver({
34
+ apiKey,
35
+ generate: async ({ filename, image, instructions: resolvedInstructions, maxTokens, responseSchema, signal })=>{
36
+ if (!image) {
37
+ throw new Error('The image was not downloaded');
38
+ }
39
+ const response = await fetch(`${baseUrl}/chat/completions`, {
40
+ body: JSON.stringify({
41
+ max_tokens: maxTokens,
42
+ messages: [
43
+ {
44
+ content: resolvedInstructions,
45
+ role: 'system'
46
+ },
47
+ {
48
+ content: [
49
+ {
50
+ type: 'image_url',
51
+ image_url: image.dataUri
52
+ },
53
+ ...filename ? [
54
+ {
55
+ type: 'text',
56
+ text: filename
57
+ }
58
+ ] : []
59
+ ],
60
+ role: 'user'
61
+ }
62
+ ],
63
+ model,
64
+ response_format: {
65
+ type: 'json_schema',
66
+ json_schema: {
67
+ name: 'data',
68
+ schema: responseSchema,
69
+ strict: true
70
+ }
71
+ }
72
+ }),
73
+ headers: {
74
+ Authorization: `Bearer ${apiKey}`,
75
+ 'Content-Type': 'application/json'
76
+ },
77
+ method: 'POST',
78
+ signal
79
+ });
80
+ if (!response.ok) {
81
+ // Bounded: unbounded provider text would land in the log as-is.
82
+ const body = (await response.text().catch(()=>'')).slice(0, 500);
83
+ throw new VisionProviderError({
84
+ body,
85
+ label: 'Mistral',
86
+ status: response.status
87
+ });
88
+ }
89
+ const completion = await response.json();
90
+ const choice = completion.choices?.[0];
91
+ if (choice?.finish_reason === 'length') {
92
+ throw new Error(`Mistral ran out of tokens before finishing the alt text (max_tokens: ${maxTokens})`);
93
+ }
94
+ const content = choice?.message?.content;
95
+ if (typeof content !== 'string') {
96
+ throw new Error('No result from Mistral');
97
+ }
98
+ try {
99
+ return JSON.parse(content);
100
+ } catch {
101
+ throw new Error('Mistral returned a response that was not valid JSON');
102
+ }
103
+ },
104
+ inlineImage: true,
105
+ instructions,
106
+ key: 'mistral',
107
+ label: 'Mistral',
108
+ // Mistral rejects images above 20 MB.
109
+ maxImageBytes: 20 * 1024 * 1024,
110
+ supportedMimeTypes: SUPPORTED_MIME_TYPES,
111
+ timeoutMs
112
+ });
113
+
114
+ //# sourceMappingURL=mistral.js.map