@jhb.software/payload-alt-text-plugin 0.10.0 → 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.
@@ -1,220 +1,26 @@
1
- import { z } from 'zod';
1
+ import { createVisionResolver, VisionProviderError } from './createVisionResolver.js';
2
2
  /**
3
3
  * Image formats the Mistral API accepts.
4
4
  *
5
5
  * Narrower than what an upload collection may hold — SVG and AVIF are missing,
6
6
  * so the endpoint rejects those documents and their generate button stays
7
7
  * disabled instead of failing at the provider.
8
+ *
9
+ * @see https://docs.mistral.ai/capabilities/vision/
8
10
  */ const SUPPORTED_MIME_TYPES = [
9
11
  'image/jpeg',
10
12
  'image/png',
11
13
  'image/gif',
12
14
  'image/webp'
13
15
  ];
14
- /** Mistral rejects images above 20 MB. */ const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
15
- const altTextSchema = z.object({
16
- altText: z.string().describe('A concise, descriptive alt text for the image'),
17
- keywords: z.array(z.string()).describe('Keywords that describe the content of the image')
18
- });
19
- /** One schema entry per requested locale, so the model must answer for all of them. */ const schemaForLocales = (locales)=>z.object(Object.fromEntries(locales.map((locale)=>[
20
- locale,
21
- altTextSchema
22
- ])));
23
- /**
24
- * Downloads the image and returns it as a data URI.
25
- *
26
- * Mistral can fetch an image URL itself, but that path is not dependable for a
27
- * CMS. It requires the file to be reachable from the public internet — never
28
- * true in local development, and not true for private buckets — and some hosts
29
- * refuse Mistral's fetcher outright, which surfaces as `File could not be
30
- * fetched from url` (error 3310). Sending the bytes removes that whole class of
31
- * failure for the price of one extra download.
32
- */ async function fetchImageAsDataUri(url, signal) {
33
- let response;
34
- try {
35
- response = await fetch(url, {
36
- signal
37
- });
38
- } catch (error) {
39
- return {
40
- error: `Could not download the image from ${url}: ${error instanceof Error ? error.message : 'unknown error'}`
41
- };
42
- }
43
- if (!response.ok) {
44
- return {
45
- error: `Could not download the image from ${url}: status ${response.status}`
46
- };
47
- }
48
- // The document's mime type is checked by the endpoint before the resolver
49
- // runs, but `getImageThumbnail` may point at a derivative in a different
50
- // format, so trust what was actually served.
51
- const contentType = response.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase();
52
- if (!contentType || !SUPPORTED_MIME_TYPES.includes(contentType)) {
53
- return {
54
- error: `The image at ${url} was served as "${contentType ?? 'an unknown type'}", which Mistral cannot read. Supported types: ${SUPPORTED_MIME_TYPES.join(', ')}.`
55
- };
56
- }
57
- const bytes = Buffer.from(await response.arrayBuffer());
58
- if (bytes.byteLength === 0) {
59
- return {
60
- error: `The image at ${url} was empty.`
61
- };
62
- }
63
- if (bytes.byteLength > MAX_IMAGE_BYTES) {
64
- return {
65
- error: `The image at ${url} is ${Math.round(bytes.byteLength / 1024 / 1024)} MB, above Mistral's 20 MB limit. Point getImageThumbnail at a smaller image size.`
66
- };
67
- }
68
- return {
69
- dataUri: `data:${contentType};base64,${bytes.toString('base64')}`
70
- };
71
- }
72
- function buildPrompt(locales) {
73
- const languages = locales.join(', ');
74
- return `
75
- You are an expert at analyzing images and creating descriptive image alt text.
76
-
77
- Please analyze the given image and provide the following in ${languages}:
78
- - A concise, 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.
79
- - A list of keywords that describe the content (e.g., ["Camel", "Palm trees", "Desert"]) as "keywords"
80
-
81
- If a context is provided, use it to enhance the alt text.
82
-
83
- Format your response as a JSON object with ${locales.map((locale)=>`"${locale}"`).join(', ')} keys, each containing "altText" and "keywords".
84
- `;
85
- }
86
- /**
87
- * Reads the model's response.
88
- *
89
- * Deliberately strict about a blank `altText`: the field is required on the
90
- * collection, so an empty string would satisfy that requirement while telling a
91
- * screen reader nothing — and nobody looks at an alt text again once it is set.
92
- */ function parseResults(content, locales) {
93
- const parsed = schemaForLocales(locales).safeParse(content);
94
- if (!parsed.success) {
95
- return null;
96
- }
97
- const results = {};
98
- for (const locale of locales){
99
- const entry = parsed.data[locale];
100
- if (entry.altText.trim().length === 0) {
101
- return null;
102
- }
103
- results[locale] = {
104
- altText: entry.altText.trim(),
105
- keywords: entry.keywords
106
- };
107
- }
108
- return results;
109
- }
110
- /**
111
- * One request, one or more locales.
112
- *
113
- * All locales go into a single call rather than one call each: the image is
114
- * uploaded and analyzed once — the expensive part — and every language ends up
115
- * describing the same reading of it.
116
- */ async function generate({ apiKey, baseUrl, filename, imageThumbnailUrl, locales, model, timeoutMs }) {
117
- if (!apiKey) {
118
- return {
119
- error: 'No Mistral API key configured',
120
- success: false
121
- };
122
- }
123
- if (locales.length === 0) {
124
- return {
125
- error: 'No locale requested',
126
- success: false
127
- };
128
- }
129
- const signal = AbortSignal.timeout(timeoutMs);
130
- const image = await fetchImageAsDataUri(imageThumbnailUrl, signal);
131
- if ('error' in image) {
132
- return {
133
- error: image.error,
134
- success: false
135
- };
136
- }
137
- try {
138
- const response = await fetch(`${baseUrl}/chat/completions`, {
139
- body: JSON.stringify({
140
- max_tokens: 150 * locales.length,
141
- messages: [
142
- {
143
- content: buildPrompt(locales),
144
- role: 'system'
145
- },
146
- {
147
- content: [
148
- {
149
- type: 'image_url',
150
- image_url: image.dataUri
151
- },
152
- ...filename ? [
153
- {
154
- type: 'text',
155
- text: filename
156
- }
157
- ] : []
158
- ],
159
- role: 'user'
160
- }
161
- ],
162
- model,
163
- response_format: {
164
- type: 'json_schema',
165
- json_schema: {
166
- name: 'data',
167
- schema: z.toJSONSchema(schemaForLocales(locales), {
168
- target: 'draft-7'
169
- }),
170
- strict: true
171
- }
172
- }
173
- }),
174
- headers: {
175
- Authorization: `Bearer ${apiKey}`,
176
- 'Content-Type': 'application/json'
177
- },
178
- method: 'POST',
179
- signal
180
- });
181
- if (!response.ok) {
182
- const body = await response.text().catch(()=>'');
183
- return {
184
- error: `Mistral responded with status ${response.status}${body ? `: ${body}` : ''}`,
185
- success: false
186
- };
187
- }
188
- const completion = await response.json();
189
- const content = completion.choices?.[0]?.message?.content;
190
- if (typeof content !== 'string') {
191
- return {
192
- error: 'No result from Mistral',
193
- success: false
194
- };
195
- }
196
- const results = parseResults(JSON.parse(content), locales);
197
- if (!results) {
198
- return {
199
- error: `Mistral did not return a usable alt text for every requested locale (${locales.join(', ')})`,
200
- success: false
201
- };
202
- }
203
- return {
204
- results,
205
- success: true
206
- };
207
- } catch (error) {
208
- console.error('Error generating alt text:', error);
209
- return {
210
- error: error instanceof Error ? error.message : 'Unknown error',
211
- success: false
212
- };
213
- }
214
- }
215
16
  /**
216
17
  * Creates a Mistral-based resolver for alt text generation.
217
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
+ *
218
24
  * @example
219
25
  * ```typescript
220
26
  * import { mistralResolver } from '@jhb.software/payload-alt-text-plugin'
@@ -224,57 +30,85 @@ function buildPrompt(locales) {
224
30
  * model: 'mistral-medium-latest', // optional, this is the default
225
31
  * })
226
32
  * ```
227
- */ export const mistralResolver = (config)=>{
228
- const { apiKey, baseUrl = 'https://api.mistral.ai/v1', model = 'mistral-medium-latest', timeoutMs = 30_000 } = config;
229
- return {
230
- key: 'mistral',
231
- resolve: async ({ filename, imageThumbnailUrl, locale })=>{
232
- const result = await generate({
233
- apiKey,
234
- baseUrl,
235
- filename,
236
- imageThumbnailUrl,
237
- locales: [
238
- locale
239
- ],
240
- model,
241
- timeoutMs
242
- });
243
- if (!result.success) {
244
- return {
245
- error: result.error,
246
- success: false
247
- };
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');
248
38
  }
249
- return {
250
- result: result.results[locale],
251
- success: true
252
- };
253
- },
254
- resolveBulk: async ({ filename, imageThumbnailUrl, locales })=>{
255
- const result = await generate({
256
- apiKey,
257
- baseUrl,
258
- filename,
259
- imageThumbnailUrl,
260
- locales,
261
- model,
262
- timeoutMs
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
263
79
  });
264
- if (!result.success) {
265
- return {
266
- error: result.error,
267
- success: false
268
- };
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');
269
102
  }
270
- return {
271
- results: result.results,
272
- success: true
273
- };
274
103
  },
275
- // https://docs.mistral.ai/capabilities/vision/
276
- supportedMimeTypes: SUPPORTED_MIME_TYPES
277
- };
278
- };
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
+ });
279
113
 
280
114
  //# sourceMappingURL=mistral.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/resolvers/mistral.ts"],"sourcesContent":["import { 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 MistralResolverConfig = {\n /** Mistral API key for authentication */\n apiKey: string\n /**\n * Base URL of the Mistral API.\n * @default 'https://api.mistral.ai/v1'\n */\n baseUrl?: string\n /**\n * The vision-capable Mistral model to use for alt text generation.\n *\n * Must be able to read images — `mistral-medium-latest`,\n * `mistral-large-latest`, `mistral-small-latest` and the `ministral-*` models\n * all are.\n *\n * @default 'mistral-medium-latest'\n */\n model?: string\n /**\n * Abort after this many milliseconds. Covers downloading the image and the\n * completion call together.\n * @default 30000\n */\n timeoutMs?: number\n}\n\n/**\n * Image formats the Mistral API accepts.\n *\n * Narrower than what an upload collection may hold — SVG and AVIF are missing,\n * so the endpoint rejects those documents and their generate button stays\n * disabled instead of failing at the provider.\n */\nconst SUPPORTED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']\n\n/** Mistral rejects images above 20 MB. */\nconst MAX_IMAGE_BYTES = 20 * 1024 * 1024\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. */\nconst schemaForLocales = (locales: string[]) =>\n z.object(Object.fromEntries(locales.map((locale) => [locale, altTextSchema])))\n\n/**\n * Downloads the image and returns it as a data URI.\n *\n * Mistral can fetch an image URL itself, but that path is not dependable for a\n * CMS. It requires the file to be reachable from the public internet — never\n * true in local development, and not true for private buckets — and some hosts\n * refuse Mistral's fetcher outright, which surfaces as `File could not be\n * fetched from url` (error 3310). Sending the bytes removes that whole class of\n * failure for the price of one extra download.\n */\nasync function fetchImageAsDataUri(\n url: string,\n signal: AbortSignal,\n): Promise<{ dataUri: string } | { error: string }> {\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 // The document's mime type is checked by the endpoint before the resolver\n // runs, but `getImageThumbnail` may point at a derivative in a different\n // format, so trust what was actually served.\n const contentType = response.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase()\n\n if (!contentType || !SUPPORTED_MIME_TYPES.includes(contentType)) {\n return {\n error: `The image at ${url} was served as \"${contentType ?? 'an unknown type'}\", which Mistral cannot read. Supported types: ${SUPPORTED_MIME_TYPES.join(', ')}.`,\n }\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 > MAX_IMAGE_BYTES) {\n return {\n error: `The image at ${url} is ${Math.round(bytes.byteLength / 1024 / 1024)} MB, above Mistral's 20 MB limit. Point getImageThumbnail at a smaller image size.`,\n }\n }\n\n return { dataUri: `data:${contentType};base64,${bytes.toString('base64')}` }\n}\n\nfunction buildPrompt(locales: string[]): string {\n const languages = locales.join(', ')\n\n return `\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 ${languages}:\n - A concise, 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 - A 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 `\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 * One request, one or more locales.\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.\n */\nasync function generate({\n apiKey,\n baseUrl,\n filename,\n imageThumbnailUrl,\n locales,\n model,\n timeoutMs,\n}: {\n apiKey: string\n baseUrl: string\n filename?: string\n imageThumbnailUrl: string\n locales: string[]\n model: string\n timeoutMs: number\n}): Promise<\n { error: string; success: false } | { results: Record<string, AltTextResult>; success: true }\n> {\n if (!apiKey) {\n return { error: 'No Mistral 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 = AbortSignal.timeout(timeoutMs)\n const image = await fetchImageAsDataUri(imageThumbnailUrl, signal)\n\n if ('error' in image) {\n return { error: image.error, success: false }\n }\n\n try {\n const response = await fetch(`${baseUrl}/chat/completions`, {\n body: JSON.stringify({\n max_tokens: 150 * locales.length,\n messages: [\n { content: buildPrompt(locales), role: 'system' },\n {\n content: [\n { type: 'image_url', image_url: image.dataUri },\n ...(filename ? [{ type: 'text', text: filename }] : []),\n ],\n role: 'user',\n },\n ],\n model,\n response_format: {\n type: 'json_schema',\n json_schema: {\n name: 'data',\n schema: z.toJSONSchema(schemaForLocales(locales), { target: 'draft-7' }),\n strict: true,\n },\n },\n }),\n headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },\n method: 'POST',\n signal,\n })\n\n if (!response.ok) {\n const body = await response.text().catch(() => '')\n\n return {\n error: `Mistral responded with status ${response.status}${body ? `: ${body}` : ''}`,\n success: false,\n }\n }\n\n const completion = (await response.json()) as {\n choices?: { message?: { content?: unknown } }[]\n }\n const content = completion.choices?.[0]?.message?.content\n\n if (typeof content !== 'string') {\n return { error: 'No result from Mistral', success: false }\n }\n\n const results = parseResults(JSON.parse(content), locales)\n\n if (!results) {\n return {\n error: `Mistral 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 console.error('Error generating alt text:', error)\n\n return { error: error instanceof Error ? error.message : 'Unknown error', success: false }\n }\n}\n\n/**\n * Creates a Mistral-based resolver for alt text generation.\n *\n * @example\n * ```typescript\n * import { mistralResolver } from '@jhb.software/payload-alt-text-plugin'\n *\n * mistralResolver({\n * apiKey: process.env.MISTRAL_API_KEY,\n * model: 'mistral-medium-latest', // optional, this is the default\n * })\n * ```\n */\nexport const mistralResolver = (config: MistralResolverConfig): AltTextResolver => {\n const {\n apiKey,\n baseUrl = 'https://api.mistral.ai/v1',\n model = 'mistral-medium-latest',\n timeoutMs = 30_000,\n } = config\n\n return {\n key: 'mistral',\n resolve: async ({\n filename,\n imageThumbnailUrl,\n locale,\n }: AltTextResolverArgs): Promise<AltTextResolverResponse> => {\n const result = await generate({\n apiKey,\n baseUrl,\n filename,\n imageThumbnailUrl,\n locales: [locale],\n model,\n timeoutMs,\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 imageThumbnailUrl,\n locales,\n }: AltTextBulkResolverArgs): Promise<AltTextBulkResolverResponse> => {\n const result = await generate({\n apiKey,\n baseUrl,\n filename,\n imageThumbnailUrl,\n locales,\n model,\n timeoutMs,\n })\n\n if (!result.success) {\n return { error: result.error, success: false }\n }\n\n return { results: result.results, success: true }\n },\n // https://docs.mistral.ai/capabilities/vision/\n supportedMimeTypes: SUPPORTED_MIME_TYPES,\n }\n}\n"],"names":["z","SUPPORTED_MIME_TYPES","MAX_IMAGE_BYTES","altTextSchema","object","altText","string","describe","keywords","array","schemaForLocales","locales","Object","fromEntries","map","locale","fetchImageAsDataUri","url","signal","response","fetch","error","Error","message","ok","status","contentType","headers","get","split","trim","toLowerCase","includes","join","bytes","Buffer","from","arrayBuffer","byteLength","Math","round","dataUri","toString","buildPrompt","languages","parseResults","content","parsed","safeParse","success","results","entry","data","length","generate","apiKey","baseUrl","filename","imageThumbnailUrl","model","timeoutMs","AbortSignal","timeout","image","body","JSON","stringify","max_tokens","messages","role","type","image_url","text","response_format","json_schema","name","schema","toJSONSchema","target","strict","Authorization","method","catch","completion","json","choices","parse","console","mistralResolver","config","key","resolve","result","resolveBulk","supportedMimeTypes"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/resolvers/mistral.ts"],"sourcesContent":["import type { VisionInstructions } from './createVisionResolver.js'\nimport type { AltTextResolver } from './types.js'\n\nimport { createVisionResolver, VisionProviderError } from './createVisionResolver.js'\n\nexport type MistralResolverConfig = {\n /** Mistral API key for authentication */\n apiKey: string\n /**\n * Base URL of the Mistral API.\n * @default 'https://api.mistral.ai/v1'\n */\n baseUrl?: string\n /**\n * Builds the instructions from the default ones, e.g. to append a house style\n * rule. Sent as the system message, separately from the image.\n *\n * @default ({ defaultInstructions }) => defaultInstructions\n */\n instructions?: VisionInstructions\n /**\n * The vision-capable Mistral model to use for alt text generation.\n *\n * Must be able to read images — `mistral-medium-latest`,\n * `mistral-large-latest`, `mistral-small-latest` and the `ministral-*` models\n * all are.\n *\n * @default 'mistral-medium-latest'\n */\n model?: string\n /**\n * Abort after this many milliseconds. Covers downloading the image and the\n * completion call together.\n * @default 30000\n */\n timeoutMs?: number\n}\n\n/**\n * Image formats the Mistral API accepts.\n *\n * Narrower than what an upload collection may hold — SVG and AVIF are missing,\n * so the endpoint rejects those documents and their generate button stays\n * disabled instead of failing at the provider.\n *\n * @see https://docs.mistral.ai/capabilities/vision/\n */\nconst SUPPORTED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']\n\n/**\n * Creates a Mistral-based resolver for alt text generation.\n *\n * The image is downloaded and sent as bytes rather than handed to Mistral as a\n * URL. Mistral's own fetcher requires a publicly reachable file — never true in\n * local development, and not true for private buckets — and some hosts refuse it\n * outright, which surfaces as `File could not be fetched from url` (error 3310).\n *\n * @example\n * ```typescript\n * import { mistralResolver } from '@jhb.software/payload-alt-text-plugin'\n *\n * mistralResolver({\n * apiKey: process.env.MISTRAL_API_KEY,\n * model: 'mistral-medium-latest', // optional, this is the default\n * })\n * ```\n */\nexport const mistralResolver = ({\n apiKey,\n baseUrl = 'https://api.mistral.ai/v1',\n instructions,\n model = 'mistral-medium-latest',\n timeoutMs = 30_000,\n}: MistralResolverConfig): AltTextResolver =>\n createVisionResolver({\n apiKey,\n generate: async ({\n filename,\n image,\n instructions: resolvedInstructions,\n maxTokens,\n responseSchema,\n signal,\n }) => {\n if (!image) {\n throw new Error('The image was not downloaded')\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n body: JSON.stringify({\n max_tokens: maxTokens,\n messages: [\n { content: resolvedInstructions, role: 'system' },\n {\n content: [\n { type: 'image_url', image_url: image.dataUri },\n ...(filename ? [{ type: 'text', text: filename }] : []),\n ],\n role: 'user',\n },\n ],\n model,\n response_format: {\n type: 'json_schema',\n json_schema: { name: 'data', schema: responseSchema, strict: true },\n },\n }),\n headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },\n method: 'POST',\n signal,\n })\n\n if (!response.ok) {\n // Bounded: unbounded provider text would land in the log as-is.\n const body = (await response.text().catch(() => '')).slice(0, 500)\n\n throw new VisionProviderError({ body, label: 'Mistral', status: response.status })\n }\n\n const completion = (await response.json()) as {\n choices?: { finish_reason?: string; message?: { content?: unknown } }[]\n }\n const choice = completion.choices?.[0]\n\n if (choice?.finish_reason === 'length') {\n throw new Error(\n `Mistral ran out of tokens before finishing the alt text (max_tokens: ${maxTokens})`,\n )\n }\n\n const content = choice?.message?.content\n\n if (typeof content !== 'string') {\n throw new Error('No result from Mistral')\n }\n\n try {\n return JSON.parse(content)\n } catch {\n throw new Error('Mistral returned a response that was not valid JSON')\n }\n },\n inlineImage: true,\n instructions,\n key: 'mistral',\n label: 'Mistral',\n // Mistral rejects images above 20 MB.\n maxImageBytes: 20 * 1024 * 1024,\n supportedMimeTypes: SUPPORTED_MIME_TYPES,\n timeoutMs,\n })\n"],"names":["createVisionResolver","VisionProviderError","SUPPORTED_MIME_TYPES","mistralResolver","apiKey","baseUrl","instructions","model","timeoutMs","generate","filename","image","resolvedInstructions","maxTokens","responseSchema","signal","Error","response","fetch","body","JSON","stringify","max_tokens","messages","content","role","type","image_url","dataUri","text","response_format","json_schema","name","schema","strict","headers","Authorization","method","ok","catch","slice","label","status","completion","json","choice","choices","finish_reason","message","parse","inlineImage","key","maxImageBytes","supportedMimeTypes"],"mappings":"AAGA,SAASA,oBAAoB,EAAEC,mBAAmB,QAAQ,4BAA2B;AAmCrF;;;;;;;;CAQC,GACD,MAAMC,uBAAuB;IAAC;IAAc;IAAa;IAAa;CAAa;AAEnF;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,MAAMC,kBAAkB,CAAC,EAC9BC,MAAM,EACNC,UAAU,2BAA2B,EACrCC,YAAY,EACZC,QAAQ,uBAAuB,EAC/BC,YAAY,MAAM,EACI,GACtBR,qBAAqB;QACnBI;QACAK,UAAU,OAAO,EACfC,QAAQ,EACRC,KAAK,EACLL,cAAcM,oBAAoB,EAClCC,SAAS,EACTC,cAAc,EACdC,MAAM,EACP;YACC,IAAI,CAACJ,OAAO;gBACV,MAAM,IAAIK,MAAM;YAClB;YAEA,MAAMC,WAAW,MAAMC,MAAM,GAAGb,QAAQ,iBAAiB,CAAC,EAAE;gBAC1Dc,MAAMC,KAAKC,SAAS,CAAC;oBACnBC,YAAYT;oBACZU,UAAU;wBACR;4BAAEC,SAASZ;4BAAsBa,MAAM;wBAAS;wBAChD;4BACED,SAAS;gCACP;oCAAEE,MAAM;oCAAaC,WAAWhB,MAAMiB,OAAO;gCAAC;mCAC1ClB,WAAW;oCAAC;wCAAEgB,MAAM;wCAAQG,MAAMnB;oCAAS;iCAAE,GAAG,EAAE;6BACvD;4BACDe,MAAM;wBACR;qBACD;oBACDlB;oBACAuB,iBAAiB;wBACfJ,MAAM;wBACNK,aAAa;4BAAEC,MAAM;4BAAQC,QAAQnB;4BAAgBoB,QAAQ;wBAAK;oBACpE;gBACF;gBACAC,SAAS;oBAAEC,eAAe,CAAC,OAAO,EAAEhC,QAAQ;oBAAE,gBAAgB;gBAAmB;gBACjFiC,QAAQ;gBACRtB;YACF;YAEA,IAAI,CAACE,SAASqB,EAAE,EAAE;gBAChB,gEAAgE;gBAChE,MAAMnB,OAAO,AAAC,CAAA,MAAMF,SAASY,IAAI,GAAGU,KAAK,CAAC,IAAM,GAAE,EAAGC,KAAK,CAAC,GAAG;gBAE9D,MAAM,IAAIvC,oBAAoB;oBAAEkB;oBAAMsB,OAAO;oBAAWC,QAAQzB,SAASyB,MAAM;gBAAC;YAClF;YAEA,MAAMC,aAAc,MAAM1B,SAAS2B,IAAI;YAGvC,MAAMC,SAASF,WAAWG,OAAO,EAAE,CAAC,EAAE;YAEtC,IAAID,QAAQE,kBAAkB,UAAU;gBACtC,MAAM,IAAI/B,MACR,CAAC,qEAAqE,EAAEH,UAAU,CAAC,CAAC;YAExF;YAEA,MAAMW,UAAUqB,QAAQG,SAASxB;YAEjC,IAAI,OAAOA,YAAY,UAAU;gBAC/B,MAAM,IAAIR,MAAM;YAClB;YAEA,IAAI;gBACF,OAAOI,KAAK6B,KAAK,CAACzB;YACpB,EAAE,OAAM;gBACN,MAAM,IAAIR,MAAM;YAClB;QACF;QACAkC,aAAa;QACb5C;QACA6C,KAAK;QACLV,OAAO;QACP,sCAAsC;QACtCW,eAAe,KAAK,OAAO;QAC3BC,oBAAoBnD;QACpBM;IACF,GAAE"}
@@ -1,13 +1,21 @@
1
+ import type { VisionInstructions } from './createVisionResolver.js';
1
2
  import type { AltTextResolver } from './types.js';
2
3
  export type OpenAIResolverConfig = {
3
4
  /** OpenAI API key for authentication */
4
5
  apiKey: string;
5
6
  /**
6
- * Base URL for the OpenAI-compatible API.
7
+ * Base URL for the OpenAI-compatible API, including the version segment.
7
8
  * Use this to point at alternative providers (e.g. Azure, Nebius, local inference).
8
- * @default undefined — the OpenAI SDK defaults to 'https://api.openai.com/v1'
9
+ * @default 'https://api.openai.com/v1'
9
10
  */
10
11
  baseUrl?: string;
12
+ /**
13
+ * Builds the instructions from the default ones, e.g. to append a house style
14
+ * rule. Sent as the system message, separately from the image.
15
+ *
16
+ * @default ({ defaultInstructions }) => defaultInstructions
17
+ */
18
+ instructions?: VisionInstructions;
11
19
  /**
12
20
  * The OpenAI LLM model to use for alt text generation.
13
21
  * @default 'gpt-4.1-nano'
@@ -23,10 +31,21 @@ export type OpenAIResolverConfig = {
23
31
  * @default ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
24
32
  */
25
33
  supportedMimeTypes?: string[];
34
+ /**
35
+ * Abort after this many milliseconds, covering the completion call and the
36
+ * retries the factory makes within it.
37
+ * @default 30000
38
+ */
39
+ timeoutMs?: number;
26
40
  };
27
41
  /**
28
42
  * Creates an OpenAI-based resolver for alt text generation.
29
43
  *
44
+ * The thumbnail URL is handed to OpenAI, which fetches it itself — so the URL
45
+ * has to be reachable from the public internet. Behind a private bucket or in
46
+ * local development, reach for a resolver that inlines the bytes instead
47
+ * (`mistralResolver`, `anthropicResolver`).
48
+ *
30
49
  * @example
31
50
  * ```typescript
32
51
  * import { openAIResolver } from '@jhb.software/payload-alt-text-plugin'
@@ -45,4 +64,4 @@ export type OpenAIResolverConfig = {
45
64
  * })
46
65
  * ```
47
66
  */
48
- export declare const openAIResolver: (config: OpenAIResolverConfig) => AltTextResolver;
67
+ export declare const openAIResolver: ({ apiKey, baseUrl, instructions, model, supportedMimeTypes, timeoutMs, }: OpenAIResolverConfig) => AltTextResolver;