@jhb.software/payload-alt-text-plugin 0.11.0 → 0.12.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.
- package/README.md +82 -39
- package/dist/components/BulkGenerateAltTextsButton.js +3 -16
- package/dist/components/BulkGenerateAltTextsButton.js.map +1 -1
- package/dist/components/summarizeBulkGenerate.d.ts +26 -0
- package/dist/components/summarizeBulkGenerate.js +56 -0
- package/dist/components/summarizeBulkGenerate.js.map +1 -0
- package/dist/endpoints/bulkGenerateAltTexts.d.ts +18 -1
- package/dist/endpoints/bulkGenerateAltTexts.js +31 -12
- package/dist/endpoints/bulkGenerateAltTexts.js.map +1 -1
- package/dist/endpoints/generateAltText.js +14 -8
- package/dist/endpoints/generateAltText.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js.map +1 -1
- package/dist/plugin.js +1 -0
- package/dist/plugin.js.map +1 -1
- package/dist/resolvers/anthropic.d.ts +3 -4
- package/dist/resolvers/anthropic.js +3 -4
- package/dist/resolvers/anthropic.js.map +1 -1
- package/dist/resolvers/createVisionResolver.d.ts +1 -1
- package/dist/resolvers/createVisionResolver.js.map +1 -1
- package/dist/resolvers/mistral.d.ts +4 -3
- package/dist/resolvers/mistral.js +4 -3
- package/dist/resolvers/mistral.js.map +1 -1
- package/dist/resolvers/openAI.d.ts +3 -3
- package/dist/resolvers/openAI.js +3 -3
- package/dist/resolvers/openAI.js.map +1 -1
- package/dist/translations/de.js +12 -4
- package/dist/translations/de.js.map +1 -1
- package/dist/translations/en.js +12 -4
- package/dist/translations/en.js.map +1 -1
- package/dist/translations/translation-schema.json +24 -8
- package/dist/types/AltTextPluginConfig.d.ts +24 -1
- package/dist/types/AltTextPluginConfig.js.map +1 -1
- package/dist/utilities/altTextHealth.d.ts +1 -1
- package/dist/utilities/altTextHealth.js +29 -4
- package/dist/utilities/altTextHealth.js.map +1 -1
- package/dist/utilities/resolveLocales.d.ts +15 -0
- package/dist/utilities/resolveLocales.js +38 -0
- package/dist/utilities/resolveLocales.js.map +1 -0
- package/package.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { APIError } from 'payload';
|
|
2
2
|
import { ZodError } from 'zod';
|
|
3
3
|
import { getUnsupportedSourceMimeTypeError, matchesMimeType } from '../utilities/mimeTypes.js';
|
|
4
|
+
import { resolveLocales } from '../utilities/resolveLocales.js';
|
|
4
5
|
import { formatZodError, generateAltTextRequestSchema } from './schemas.js';
|
|
5
6
|
/**
|
|
6
7
|
* Generates alt text for a single image using the configured resolver.
|
|
@@ -92,15 +93,20 @@ import { formatZodError, generateAltTextRequestSchema } from './schemas.js';
|
|
|
92
93
|
status: 400
|
|
93
94
|
});
|
|
94
95
|
}
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}, {
|
|
102
|
-
status: 400
|
|
96
|
+
// Reject a locale this request may not write before it reaches the
|
|
97
|
+
// document or the resolver's prompt, which interpolates it verbatim.
|
|
98
|
+
if (locale != null && pluginConfig.locales.length > 0) {
|
|
99
|
+
const availableLocales = await resolveLocales({
|
|
100
|
+
pluginConfig,
|
|
101
|
+
req
|
|
103
102
|
});
|
|
103
|
+
if (!availableLocales.includes(locale)) {
|
|
104
|
+
return Response.json({
|
|
105
|
+
error: `Locale "${locale}" is not available. Available locales: ${availableLocales.join(', ')}.`
|
|
106
|
+
}, {
|
|
107
|
+
status: 400
|
|
108
|
+
});
|
|
109
|
+
}
|
|
104
110
|
}
|
|
105
111
|
// determine target locale
|
|
106
112
|
const targetLocale = locale ?? pluginConfig.locale;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/endpoints/generateAltText.ts"],"sourcesContent":["import type { PayloadHandler, PayloadRequest } from 'payload'\n\nimport { APIError } from 'payload'\nimport { ZodError } from 'zod'\n\nimport type { AltTextPluginConfig } from '../types/AltTextPluginConfig.js'\n\nimport { getUnsupportedSourceMimeTypeError, matchesMimeType } from '../utilities/mimeTypes.js'\nimport { formatZodError, generateAltTextRequestSchema } from './schemas.js'\n\n/**\n * Generates alt text for a single image using the configured resolver.\n *\n * By default, returns the result without updating the document (preview mode).\n * Pass `update: true` in the request body to also persist the generated alt text\n * and keywords to the document — useful for programmatic/agent workflows.\n *\n * The response always includes the `id` and `collection` for easy correlation.\n */\nexport const generateAltTextEndpoint =\n (access: AltTextPluginConfig['access']): PayloadHandler =>\n async (req: PayloadRequest) => {\n try {\n if (!(await access({ req }))) {\n return Response.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n const data = 'json' in req && typeof req.json === 'function' ? await req.json() : null\n\n const { id, collection, locale, update } = generateAltTextRequestSchema.parse(data)\n\n const pluginConfig = req.payload.config.custom?.altTextPluginConfig as\n AltTextPluginConfig | undefined\n\n if (!pluginConfig) {\n return Response.json({ error: 'Plugin config not found' }, { status: 500 })\n }\n\n // Treat the configured collections as an allowlist. Reject any other\n // collection before touching the Local API, so the endpoint can only ever\n // operate on the upload collections the plugin manages.\n const collectionConfig = pluginConfig.collections.find((entry) => entry.slug === collection)\n\n if (!collectionConfig) {\n return Response.json(\n { error: `Collection \"${collection}\" is not managed by the alt text plugin.` },\n { status: 403 },\n )\n }\n\n const imageDoc = await req.payload.findByID({\n id,\n collection,\n depth: 0,\n // Run under the requesting user's access, not Payload's default\n // `overrideAccess: true`, so collection-level access control applies.\n overrideAccess: false,\n user: req.user,\n })\n\n if (!imageDoc) {\n return Response.json({ error: 'Image not found' }, { status: 404 })\n }\n\n if (!pluginConfig.getImageThumbnail) {\n return Response.json(\n { error: 'getImageThumbnail function not configured' },\n { status: 500 },\n )\n }\n\n if (!pluginConfig.resolver) {\n return Response.json({ error: 'No alt text resolver configured' }, { status: 500 })\n }\n\n const mimeType =\n 'mimeType' in imageDoc && typeof imageDoc.mimeType === 'string'\n ? imageDoc.mimeType\n : undefined\n\n if (mimeType && !matchesMimeType(mimeType, collectionConfig.mimeTypes)) {\n return Response.json(\n {\n error: `Alt text is not tracked for files of type \"${mimeType}\" in the \"${collection}\" collection. Tracked types: ${collectionConfig.mimeTypes.join(', ')}.`,\n },\n { status: 400 },\n )\n }\n\n const unsupportedSourceError = getUnsupportedSourceMimeTypeError({\n declaredThumbnailMimeType: collectionConfig.imageThumbnailMimeType,\n mimeType,\n supportedMimeTypes: pluginConfig.resolver.supportedMimeTypes,\n })\n if (unsupportedSourceError) {\n return Response.json({ error: unsupportedSourceError }, { status: 400 })\n }\n\n // When localization is enabled, the requested locale must be one of the\n // configured locales. Reject anything else before it can be written to an\n // unconfigured locale or interpolated into the resolver's prompt.\n if (\n locale != null &&\n pluginConfig.locales.length > 0 &&\n !pluginConfig.locales.includes(locale)\n ) {\n return Response.json(\n {\n error: `Locale \"${locale}\" is not configured. Configured locales: ${pluginConfig.locales.join(', ')}.`,\n },\n { status: 400 },\n )\n }\n\n // determine target locale\n const targetLocale = locale ?? pluginConfig.locale\n if (!targetLocale) {\n return Response.json(\n {\n error:\n 'Could not determine target locale for alt text generation. Please check your plugin configuration.',\n },\n { status: 500 },\n )\n }\n\n const imageThumbnailUrl = await pluginConfig.getImageThumbnail(imageDoc, { collection, req })\n\n const result = await pluginConfig.resolver.resolve({\n filename:\n 'filename' in imageDoc && typeof imageDoc.filename === 'string'\n ? imageDoc.filename\n : undefined,\n imageThumbnailMimeType: collectionConfig.imageThumbnailMimeType,\n imageThumbnailUrl,\n locale: targetLocale,\n req,\n })\n\n if (!result.success) {\n return Response.json(\n { error: result.error || 'Failed to generate alt text' },\n { status: 500 },\n )\n }\n\n if (update) {\n await req.payload.update({\n id,\n collection,\n data: {\n alt: result.result.altText,\n keywords: result.result.keywords,\n },\n locale: targetLocale,\n // Run under the requesting user's access, not Payload's default\n // `overrideAccess: true`, so collection-level access control applies.\n overrideAccess: false,\n user: req.user,\n })\n }\n\n return Response.json({ id, collection, ...result.result })\n } catch (error) {\n if (error instanceof ZodError) {\n return Response.json(formatZodError(error), { status: 400 })\n }\n // Surface Payload access errors (Forbidden 403 / NotFound 404) with their\n // real status so an agent gets an accurate, non-retryable signal instead\n // of a misleading 500.\n if (error instanceof APIError) {\n return Response.json({ error: error.message }, { status: error.status })\n }\n req.payload.logger.error({ err: error }, 'Error generating alt text')\n return Response.json(\n {\n error: `Error generating alt text: ${error instanceof Error ? error.message : 'Unknown error'}`,\n },\n { status: 500 },\n )\n }\n }\n"],"names":["APIError","ZodError","getUnsupportedSourceMimeTypeError","matchesMimeType","formatZodError","generateAltTextRequestSchema","generateAltTextEndpoint","access","req","Response","json","error","status","data","id","collection","locale","update","parse","pluginConfig","payload","config","custom","altTextPluginConfig","collectionConfig","collections","find","entry","slug","imageDoc","findByID","depth","overrideAccess","user","getImageThumbnail","resolver","mimeType","undefined","mimeTypes","join","unsupportedSourceError","declaredThumbnailMimeType","imageThumbnailMimeType","supportedMimeTypes","locales","length","includes","targetLocale","imageThumbnailUrl","result","resolve","filename","success","alt","altText","keywords","message","logger","err","Error"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../src/endpoints/generateAltText.ts"],"sourcesContent":["import type { PayloadHandler, PayloadRequest } from 'payload'\n\nimport { APIError } from 'payload'\nimport { ZodError } from 'zod'\n\nimport type { AltTextPluginConfig } from '../types/AltTextPluginConfig.js'\n\nimport { getUnsupportedSourceMimeTypeError, matchesMimeType } from '../utilities/mimeTypes.js'\nimport { resolveLocales } from '../utilities/resolveLocales.js'\nimport { formatZodError, generateAltTextRequestSchema } from './schemas.js'\n\n/**\n * Generates alt text for a single image using the configured resolver.\n *\n * By default, returns the result without updating the document (preview mode).\n * Pass `update: true` in the request body to also persist the generated alt text\n * and keywords to the document — useful for programmatic/agent workflows.\n *\n * The response always includes the `id` and `collection` for easy correlation.\n */\nexport const generateAltTextEndpoint =\n (access: AltTextPluginConfig['access']): PayloadHandler =>\n async (req: PayloadRequest) => {\n try {\n if (!(await access({ req }))) {\n return Response.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n const data = 'json' in req && typeof req.json === 'function' ? await req.json() : null\n\n const { id, collection, locale, update } = generateAltTextRequestSchema.parse(data)\n\n const pluginConfig = req.payload.config.custom?.altTextPluginConfig as\n AltTextPluginConfig | undefined\n\n if (!pluginConfig) {\n return Response.json({ error: 'Plugin config not found' }, { status: 500 })\n }\n\n // Treat the configured collections as an allowlist. Reject any other\n // collection before touching the Local API, so the endpoint can only ever\n // operate on the upload collections the plugin manages.\n const collectionConfig = pluginConfig.collections.find((entry) => entry.slug === collection)\n\n if (!collectionConfig) {\n return Response.json(\n { error: `Collection \"${collection}\" is not managed by the alt text plugin.` },\n { status: 403 },\n )\n }\n\n const imageDoc = await req.payload.findByID({\n id,\n collection,\n depth: 0,\n // Run under the requesting user's access, not Payload's default\n // `overrideAccess: true`, so collection-level access control applies.\n overrideAccess: false,\n user: req.user,\n })\n\n if (!imageDoc) {\n return Response.json({ error: 'Image not found' }, { status: 404 })\n }\n\n if (!pluginConfig.getImageThumbnail) {\n return Response.json(\n { error: 'getImageThumbnail function not configured' },\n { status: 500 },\n )\n }\n\n if (!pluginConfig.resolver) {\n return Response.json({ error: 'No alt text resolver configured' }, { status: 500 })\n }\n\n const mimeType =\n 'mimeType' in imageDoc && typeof imageDoc.mimeType === 'string'\n ? imageDoc.mimeType\n : undefined\n\n if (mimeType && !matchesMimeType(mimeType, collectionConfig.mimeTypes)) {\n return Response.json(\n {\n error: `Alt text is not tracked for files of type \"${mimeType}\" in the \"${collection}\" collection. Tracked types: ${collectionConfig.mimeTypes.join(', ')}.`,\n },\n { status: 400 },\n )\n }\n\n const unsupportedSourceError = getUnsupportedSourceMimeTypeError({\n declaredThumbnailMimeType: collectionConfig.imageThumbnailMimeType,\n mimeType,\n supportedMimeTypes: pluginConfig.resolver.supportedMimeTypes,\n })\n if (unsupportedSourceError) {\n return Response.json({ error: unsupportedSourceError }, { status: 400 })\n }\n\n // Reject a locale this request may not write before it reaches the\n // document or the resolver's prompt, which interpolates it verbatim.\n if (locale != null && pluginConfig.locales.length > 0) {\n const availableLocales = await resolveLocales({ pluginConfig, req })\n\n if (!availableLocales.includes(locale)) {\n return Response.json(\n {\n error: `Locale \"${locale}\" is not available. Available locales: ${availableLocales.join(', ')}.`,\n },\n { status: 400 },\n )\n }\n }\n\n // determine target locale\n const targetLocale = locale ?? pluginConfig.locale\n if (!targetLocale) {\n return Response.json(\n {\n error:\n 'Could not determine target locale for alt text generation. Please check your plugin configuration.',\n },\n { status: 500 },\n )\n }\n\n const imageThumbnailUrl = await pluginConfig.getImageThumbnail(imageDoc, { collection, req })\n\n const result = await pluginConfig.resolver.resolve({\n filename:\n 'filename' in imageDoc && typeof imageDoc.filename === 'string'\n ? imageDoc.filename\n : undefined,\n imageThumbnailMimeType: collectionConfig.imageThumbnailMimeType,\n imageThumbnailUrl,\n locale: targetLocale,\n req,\n })\n\n if (!result.success) {\n return Response.json(\n { error: result.error || 'Failed to generate alt text' },\n { status: 500 },\n )\n }\n\n if (update) {\n await req.payload.update({\n id,\n collection,\n data: {\n alt: result.result.altText,\n keywords: result.result.keywords,\n },\n locale: targetLocale,\n // Run under the requesting user's access, not Payload's default\n // `overrideAccess: true`, so collection-level access control applies.\n overrideAccess: false,\n user: req.user,\n })\n }\n\n return Response.json({ id, collection, ...result.result })\n } catch (error) {\n if (error instanceof ZodError) {\n return Response.json(formatZodError(error), { status: 400 })\n }\n // Surface Payload access errors (Forbidden 403 / NotFound 404) with their\n // real status so an agent gets an accurate, non-retryable signal instead\n // of a misleading 500.\n if (error instanceof APIError) {\n return Response.json({ error: error.message }, { status: error.status })\n }\n req.payload.logger.error({ err: error }, 'Error generating alt text')\n return Response.json(\n {\n error: `Error generating alt text: ${error instanceof Error ? error.message : 'Unknown error'}`,\n },\n { status: 500 },\n )\n }\n }\n"],"names":["APIError","ZodError","getUnsupportedSourceMimeTypeError","matchesMimeType","resolveLocales","formatZodError","generateAltTextRequestSchema","generateAltTextEndpoint","access","req","Response","json","error","status","data","id","collection","locale","update","parse","pluginConfig","payload","config","custom","altTextPluginConfig","collectionConfig","collections","find","entry","slug","imageDoc","findByID","depth","overrideAccess","user","getImageThumbnail","resolver","mimeType","undefined","mimeTypes","join","unsupportedSourceError","declaredThumbnailMimeType","imageThumbnailMimeType","supportedMimeTypes","locales","length","availableLocales","includes","targetLocale","imageThumbnailUrl","result","resolve","filename","success","alt","altText","keywords","message","logger","err","Error"],"mappings":"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"}
|
package/dist/index.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ export type { MistralResolverConfig } from './resolvers/mistral.js';
|
|
|
8
8
|
export { openAIResolver } from './resolvers/openAI.js';
|
|
9
9
|
export type { OpenAIResolverConfig } from './resolvers/openAI.js';
|
|
10
10
|
export * from './resolvers/types.js';
|
|
11
|
-
export type { AltTextCollectionConfig, AltTextHealthBaseFilter, AltTextHealthCheckConfig, GetImageThumbnail, IncomingAltTextPluginConfig as AltTextPluginConfig, } from './types/AltTextPluginConfig.js';
|
|
11
|
+
export type { AltTextCollectionConfig, AltTextHealthBaseFilter, AltTextHealthCheckConfig, FilterLocales, GetImageThumbnail, IncomingAltTextPluginConfig as AltTextPluginConfig, } from './types/AltTextPluginConfig.js';
|
|
12
12
|
export { getAltTextHealth } from './utilities/altTextHealth.js';
|
|
13
13
|
export type { AltTextHealthError, AltTextHealthErrorCode, AltTextHealthScan, AltTextHealthScanCollection, } from './utilities/altTextHealth.js';
|
|
14
14
|
export { matchesMimeType, validateAltText } from './utilities/mimeTypes.js';
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["export { payloadAltTextPlugin } from './plugin.js'\nexport { anthropicResolver } from './resolvers/anthropic.js'\nexport type { AnthropicResolverConfig } from './resolvers/anthropic.js'\nexport { createVisionResolver, VisionProviderError } from './resolvers/createVisionResolver.js'\nexport type {\n VisionGenerateArgs,\n VisionImage,\n VisionInstructions,\n VisionInstructionsArgs,\n VisionResolverConfig,\n} from './resolvers/createVisionResolver.js'\nexport { mistralResolver } from './resolvers/mistral.js'\nexport type { MistralResolverConfig } from './resolvers/mistral.js'\nexport { openAIResolver } from './resolvers/openAI.js'\nexport type { OpenAIResolverConfig } from './resolvers/openAI.js'\nexport * from './resolvers/types.js'\nexport type {\n AltTextCollectionConfig,\n AltTextHealthBaseFilter,\n AltTextHealthCheckConfig,\n GetImageThumbnail,\n IncomingAltTextPluginConfig as AltTextPluginConfig,\n} from './types/AltTextPluginConfig.js'\nexport { getAltTextHealth } from './utilities/altTextHealth.js'\nexport type {\n AltTextHealthError,\n AltTextHealthErrorCode,\n AltTextHealthScan,\n AltTextHealthScanCollection,\n} from './utilities/altTextHealth.js'\nexport { matchesMimeType, validateAltText } from './utilities/mimeTypes.js'\n"],"names":["payloadAltTextPlugin","anthropicResolver","createVisionResolver","VisionProviderError","mistralResolver","openAIResolver","getAltTextHealth","matchesMimeType","validateAltText"],"mappings":"AAAA,SAASA,oBAAoB,QAAQ,cAAa;AAClD,SAASC,iBAAiB,QAAQ,2BAA0B;AAE5D,SAASC,oBAAoB,EAAEC,mBAAmB,QAAQ,sCAAqC;AAQ/F,SAASC,eAAe,QAAQ,yBAAwB;AAExD,SAASC,cAAc,QAAQ,wBAAuB;AAEtD,cAAc,uBAAsB;
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["export { payloadAltTextPlugin } from './plugin.js'\nexport { anthropicResolver } from './resolvers/anthropic.js'\nexport type { AnthropicResolverConfig } from './resolvers/anthropic.js'\nexport { createVisionResolver, VisionProviderError } from './resolvers/createVisionResolver.js'\nexport type {\n VisionGenerateArgs,\n VisionImage,\n VisionInstructions,\n VisionInstructionsArgs,\n VisionResolverConfig,\n} from './resolvers/createVisionResolver.js'\nexport { mistralResolver } from './resolvers/mistral.js'\nexport type { MistralResolverConfig } from './resolvers/mistral.js'\nexport { openAIResolver } from './resolvers/openAI.js'\nexport type { OpenAIResolverConfig } from './resolvers/openAI.js'\nexport * from './resolvers/types.js'\nexport type {\n AltTextCollectionConfig,\n AltTextHealthBaseFilter,\n AltTextHealthCheckConfig,\n FilterLocales,\n GetImageThumbnail,\n IncomingAltTextPluginConfig as AltTextPluginConfig,\n} from './types/AltTextPluginConfig.js'\nexport { getAltTextHealth } from './utilities/altTextHealth.js'\nexport type {\n AltTextHealthError,\n AltTextHealthErrorCode,\n AltTextHealthScan,\n AltTextHealthScanCollection,\n} from './utilities/altTextHealth.js'\nexport { matchesMimeType, validateAltText } from './utilities/mimeTypes.js'\n"],"names":["payloadAltTextPlugin","anthropicResolver","createVisionResolver","VisionProviderError","mistralResolver","openAIResolver","getAltTextHealth","matchesMimeType","validateAltText"],"mappings":"AAAA,SAASA,oBAAoB,QAAQ,cAAa;AAClD,SAASC,iBAAiB,QAAQ,2BAA0B;AAE5D,SAASC,oBAAoB,EAAEC,mBAAmB,QAAQ,sCAAqC;AAQ/F,SAASC,eAAe,QAAQ,yBAAwB;AAExD,SAASC,cAAc,QAAQ,wBAAuB;AAEtD,cAAc,uBAAsB;AASpC,SAASC,gBAAgB,QAAQ,+BAA8B;AAO/D,SAASC,eAAe,EAAEC,eAAe,QAAQ,2BAA0B"}
|
package/dist/plugin.js
CHANGED
|
@@ -63,6 +63,7 @@ export const payloadAltTextPlugin = (incomingPluginConfig)=>(incomingConfig)=>{
|
|
|
63
63
|
collections: normalizedCollections,
|
|
64
64
|
enabled: incomingPluginConfig.enabled ?? true,
|
|
65
65
|
fieldsOverride: incomingPluginConfig.fieldsOverride,
|
|
66
|
+
filterLocales: incomingPluginConfig.filterLocales,
|
|
66
67
|
getImageThumbnail: incomingPluginConfig.getImageThumbnail,
|
|
67
68
|
healthCheck: enableHealthCheck,
|
|
68
69
|
healthCheckAccess,
|
package/dist/plugin.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/plugin.ts"],"sourcesContent":["import type { Config, Widget } from 'payload'\n\nimport type {\n AltTextPluginConfig,\n IncomingAltTextPluginConfig,\n} from './types/AltTextPluginConfig.js'\n\nimport { PLUGIN_SLUG } from './constants.js'\nimport { altTextHealthEndpoint } from './endpoints/altTextHealth.js'\nimport { bulkGenerateAltTextsEndpoint } from './endpoints/bulkGenerateAltTexts.js'\nimport { generateAltTextEndpoint } from './endpoints/generateAltText.js'\nimport { altTextField } from './fields/altTextField.js'\nimport { keywordsField } from './fields/keywordsField.js'\nimport {\n createRevalidateAltTextHealthAfterChangeHook,\n createRevalidateAltTextHealthAfterDeleteHook,\n} from './hooks/revalidateAltTextHealth.js'\nimport { translations } from './translations/index.js'\nimport { isValidMimeType, normalizeCollectionsConfig } from './utilities/mimeTypes.js'\nimport { deepMergeSimple } from './utils/deepMergeSimple.js'\n\nconst altTextHealthWidgetDefinition = {\n slug: 'alt-text-health',\n // `Component` was renamed from `ComponentPath` in Payload 3.79.0. Set both for backward compatibility.\n Component: '@jhb.software/payload-alt-text-plugin/server#AltTextHealthWidget',\n ComponentPath: '@jhb.software/payload-alt-text-plugin/server#AltTextHealthWidget',\n label: {\n de: 'Alternativtexte Zustand',\n en: 'Alt text health',\n },\n maxWidth: 'full',\n minWidth: 'medium',\n} satisfies { ComponentPath: string } & Widget\n\nexport const payloadAltTextPlugin =\n (incomingPluginConfig: IncomingAltTextPluginConfig) =>\n (incomingConfig: Config): Config => {\n const config = { ...incomingConfig }\n\n // If the plugin is disabled, return the config without modifying it\n if (incomingPluginConfig.enabled === false) {\n return config\n }\n\n const locales = config.localization\n ? config.localization.locales.map((localeConfig) =>\n typeof localeConfig === 'string' ? localeConfig : localeConfig.code,\n )\n : []\n\n const enableHealthCheck = incomingPluginConfig.healthCheck !== false\n\n const normalizedCollections = normalizeCollectionsConfig(incomingPluginConfig.collections, {\n imageThumbnailMimeType: incomingPluginConfig.imageThumbnailMimeType,\n })\n\n // A declared thumbnail MIME type replaces the per-document source check, so a\n // wrong one fails at boot rather than as a silently missing guard or a 500 per\n // image.\n const supportedMimeTypes = incomingPluginConfig.resolver.supportedMimeTypes\n for (const collection of normalizedCollections) {\n const declared = collection.imageThumbnailMimeType\n if (declared === undefined) {\n continue\n }\n\n if (!isValidMimeType(declared)) {\n throw new Error(\n `The alt-text plugin is configured with imageThumbnailMimeType \"${declared}\" for the \"${collection.slug}\" collection, ` +\n 'but that is not a valid MIME type. Expected something like \"image/webp\".',\n )\n }\n\n if (supportedMimeTypes && !supportedMimeTypes.includes(declared)) {\n throw new Error(\n `The alt-text plugin is configured with imageThumbnailMimeType \"${declared}\" for the \"${collection.slug}\" collection, ` +\n `but the \"${incomingPluginConfig.resolver.key}\" resolver does not support it. ` +\n `Supported types: ${supportedMimeTypes.join(', ')}. ` +\n \"Either change the transformation in getImageThumbnail, or remove the declaration to fall back to checking each document's own mime type.\",\n )\n }\n }\n\n const access = incomingPluginConfig.access ?? (({ req }) => !!req.user)\n\n // The former function form was the health report's access gate. Accepting it\n // silently would widen that gate to the plugin's `access`, so it fails at boot.\n if (typeof incomingPluginConfig.healthCheck === 'function') {\n throw new Error(\n 'The alt-text plugin no longer accepts a function for `healthCheck`. ' +\n 'Move the access check to `healthCheck: { access: ({ req }) => ... }`.',\n )\n }\n\n const healthCheckConfig =\n typeof incomingPluginConfig.healthCheck === 'object' ? incomingPluginConfig.healthCheck : {}\n\n // The health report's own gate, falling back to the shared `access`.\n const healthCheckAccess = healthCheckConfig.access ?? access\n\n const pluginConfig: AltTextPluginConfig = {\n access,\n collections: normalizedCollections,\n enabled: incomingPluginConfig.enabled ?? true,\n fieldsOverride: incomingPluginConfig.fieldsOverride,\n getImageThumbnail: incomingPluginConfig.getImageThumbnail,\n healthCheck: enableHealthCheck,\n healthCheckAccess,\n healthCheckBaseFilter: healthCheckConfig.baseFilter,\n locale: incomingPluginConfig.locale,\n locales,\n maxBulkGenerateConcurrency: incomingPluginConfig.maxBulkGenerateConcurrency ?? 16,\n maxBulkGenerateIds: incomingPluginConfig.maxBulkGenerateIds ?? 100,\n resolver: incomingPluginConfig.resolver,\n }\n\n // Validate locale requirement for non-localized mode\n if (locales.length === 0 && !incomingPluginConfig.locale) {\n throw new Error(\n 'The alt-text plugin requires a \"locale\" option when Payload localization is disabled. ' +\n 'Please add { locale: \"en\" } (or your preferred locale) to your plugin configuration.',\n )\n }\n\n const collectionConfigBySlug = new Map<string, (typeof normalizedCollections)[number]>(\n normalizedCollections.map((entry) => [entry.slug, entry]),\n )\n\n // Collected while collections are mapped and flushed in `onInit`: no Payload instance —\n // and therefore no logger — exists while the config is still being built.\n const configWarnings: string[] = []\n\n // Ensure collections array exists\n config.collections = config.collections || []\n\n // Map over collections and inject AI alt text fields into specified ones\n config.collections = config.collections.map((collectionConfig) => {\n const altTextCollectionConfig = collectionConfigBySlug.get(collectionConfig.slug)\n\n if (altTextCollectionConfig) {\n if (!collectionConfig.upload) {\n configWarnings.push(\n `AI Alt Text Plugin: Collection \"${collectionConfig.slug}\" is not an upload collection. Skipping field injection.`,\n )\n return collectionConfig\n }\n\n const defaultFields = [\n altTextField({\n localized: Boolean(config.localization),\n // When the collection declares what getImageThumbnail delivers, the\n // document's own mime type says nothing about whether generation can\n // succeed — so don't let the admin UI disable the button on it.\n supportedMimeTypes: altTextCollectionConfig.imageThumbnailMimeType\n ? undefined\n : pluginConfig.resolver.supportedMimeTypes,\n trackedMimeTypes: altTextCollectionConfig.mimeTypes,\n validate: altTextCollectionConfig.validate,\n }),\n keywordsField({\n localized: Boolean(config.localization),\n }),\n ]\n\n const fields =\n incomingPluginConfig.fieldsOverride &&\n typeof incomingPluginConfig.fieldsOverride === 'function'\n ? incomingPluginConfig.fieldsOverride({ defaultFields })\n : defaultFields\n\n return {\n ...collectionConfig,\n admin: {\n ...collectionConfig.admin,\n components: {\n ...(collectionConfig.admin?.components ?? {}),\n // TODO: use the beforeBulkAction custom component slot once available: https://github.com/payloadcms/payload/pull/11719\n beforeListTable: [\n ...(collectionConfig.admin?.components?.beforeListTable ?? []),\n {\n path: '@jhb.software/payload-alt-text-plugin/client#BulkGenerateAltTextsButton',\n props: {\n collectionSlug: collectionConfig.slug,\n },\n },\n ],\n },\n // enhance the search by adding the filename, keywords and alt fields (if the user has not provided their own listSearchableFields)\n listSearchableFields: collectionConfig.admin?.listSearchableFields ?? [\n 'filename',\n 'keywords',\n 'alt',\n ],\n },\n fields: [...(collectionConfig.fields ?? []), ...fields],\n hooks: {\n ...collectionConfig.hooks,\n ...(enableHealthCheck && {\n afterChange: [\n ...(collectionConfig.hooks?.afterChange ?? []),\n createRevalidateAltTextHealthAfterChangeHook(collectionConfig.slug),\n ],\n afterDelete: [\n ...(collectionConfig.hooks?.afterDelete ?? []),\n createRevalidateAltTextHealthAfterDeleteHook(collectionConfig.slug),\n ],\n }),\n },\n }\n }\n\n return collectionConfig\n })\n\n const existingWidgets = config.admin?.dashboard?.widgets ?? []\n const widgets =\n !enableHealthCheck || existingWidgets.some((widget) => widget.slug === 'alt-text-health')\n ? existingWidgets\n : [...existingWidgets, altTextHealthWidgetDefinition]\n\n return {\n ...config,\n admin: {\n ...config.admin,\n dashboard: {\n ...config.admin?.dashboard,\n widgets,\n },\n },\n custom: {\n ...config.custom,\n // Make plugin config available in hooks/actions\n altTextPluginConfig: pluginConfig,\n },\n endpoints: [\n ...(config.endpoints ?? []),\n {\n handler: generateAltTextEndpoint(pluginConfig.access),\n method: 'post',\n path: `/${PLUGIN_SLUG}/generate`,\n },\n {\n handler: bulkGenerateAltTextsEndpoint(pluginConfig.access),\n method: 'post',\n path: `/${PLUGIN_SLUG}/generate/bulk`,\n },\n ...(enableHealthCheck\n ? [\n {\n handler: altTextHealthEndpoint(pluginConfig.healthCheckAccess),\n method: 'get' as const,\n path: `/${PLUGIN_SLUG}/health`,\n },\n ]\n : []),\n ],\n i18n: {\n ...config.i18n,\n translations: deepMergeSimple(translations, incomingConfig.i18n?.translations ?? {}),\n },\n onInit: async (payload) => {\n for (const warning of configWarnings) {\n payload.logger.warn(warning)\n }\n\n await config.onInit?.(payload)\n },\n }\n }\n"],"names":["PLUGIN_SLUG","altTextHealthEndpoint","bulkGenerateAltTextsEndpoint","generateAltTextEndpoint","altTextField","keywordsField","createRevalidateAltTextHealthAfterChangeHook","createRevalidateAltTextHealthAfterDeleteHook","translations","isValidMimeType","normalizeCollectionsConfig","deepMergeSimple","altTextHealthWidgetDefinition","slug","Component","ComponentPath","label","de","en","maxWidth","minWidth","payloadAltTextPlugin","incomingPluginConfig","incomingConfig","config","enabled","locales","localization","map","localeConfig","code","enableHealthCheck","healthCheck","normalizedCollections","collections","imageThumbnailMimeType","supportedMimeTypes","resolver","collection","declared","undefined","Error","includes","key","join","access","req","user","healthCheckConfig","healthCheckAccess","pluginConfig","fieldsOverride","getImageThumbnail","healthCheckBaseFilter","baseFilter","locale","maxBulkGenerateConcurrency","maxBulkGenerateIds","length","collectionConfigBySlug","Map","entry","configWarnings","collectionConfig","altTextCollectionConfig","get","upload","push","defaultFields","localized","Boolean","trackedMimeTypes","mimeTypes","validate","fields","admin","components","beforeListTable","path","props","collectionSlug","listSearchableFields","hooks","afterChange","afterDelete","existingWidgets","dashboard","widgets","some","widget","custom","altTextPluginConfig","endpoints","handler","method","i18n","onInit","payload","warning","logger","warn"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../src/plugin.ts"],"sourcesContent":["import type { Config, Widget } from 'payload'\n\nimport type {\n AltTextPluginConfig,\n IncomingAltTextPluginConfig,\n} from './types/AltTextPluginConfig.js'\n\nimport { PLUGIN_SLUG } from './constants.js'\nimport { altTextHealthEndpoint } from './endpoints/altTextHealth.js'\nimport { bulkGenerateAltTextsEndpoint } from './endpoints/bulkGenerateAltTexts.js'\nimport { generateAltTextEndpoint } from './endpoints/generateAltText.js'\nimport { altTextField } from './fields/altTextField.js'\nimport { keywordsField } from './fields/keywordsField.js'\nimport {\n createRevalidateAltTextHealthAfterChangeHook,\n createRevalidateAltTextHealthAfterDeleteHook,\n} from './hooks/revalidateAltTextHealth.js'\nimport { translations } from './translations/index.js'\nimport { isValidMimeType, normalizeCollectionsConfig } from './utilities/mimeTypes.js'\nimport { deepMergeSimple } from './utils/deepMergeSimple.js'\n\nconst altTextHealthWidgetDefinition = {\n slug: 'alt-text-health',\n // `Component` was renamed from `ComponentPath` in Payload 3.79.0. Set both for backward compatibility.\n Component: '@jhb.software/payload-alt-text-plugin/server#AltTextHealthWidget',\n ComponentPath: '@jhb.software/payload-alt-text-plugin/server#AltTextHealthWidget',\n label: {\n de: 'Alternativtexte Zustand',\n en: 'Alt text health',\n },\n maxWidth: 'full',\n minWidth: 'medium',\n} satisfies { ComponentPath: string } & Widget\n\nexport const payloadAltTextPlugin =\n (incomingPluginConfig: IncomingAltTextPluginConfig) =>\n (incomingConfig: Config): Config => {\n const config = { ...incomingConfig }\n\n // If the plugin is disabled, return the config without modifying it\n if (incomingPluginConfig.enabled === false) {\n return config\n }\n\n const locales = config.localization\n ? config.localization.locales.map((localeConfig) =>\n typeof localeConfig === 'string' ? localeConfig : localeConfig.code,\n )\n : []\n\n const enableHealthCheck = incomingPluginConfig.healthCheck !== false\n\n const normalizedCollections = normalizeCollectionsConfig(incomingPluginConfig.collections, {\n imageThumbnailMimeType: incomingPluginConfig.imageThumbnailMimeType,\n })\n\n // A declared thumbnail MIME type replaces the per-document source check, so a\n // wrong one fails at boot rather than as a silently missing guard or a 500 per\n // image.\n const supportedMimeTypes = incomingPluginConfig.resolver.supportedMimeTypes\n for (const collection of normalizedCollections) {\n const declared = collection.imageThumbnailMimeType\n if (declared === undefined) {\n continue\n }\n\n if (!isValidMimeType(declared)) {\n throw new Error(\n `The alt-text plugin is configured with imageThumbnailMimeType \"${declared}\" for the \"${collection.slug}\" collection, ` +\n 'but that is not a valid MIME type. Expected something like \"image/webp\".',\n )\n }\n\n if (supportedMimeTypes && !supportedMimeTypes.includes(declared)) {\n throw new Error(\n `The alt-text plugin is configured with imageThumbnailMimeType \"${declared}\" for the \"${collection.slug}\" collection, ` +\n `but the \"${incomingPluginConfig.resolver.key}\" resolver does not support it. ` +\n `Supported types: ${supportedMimeTypes.join(', ')}. ` +\n \"Either change the transformation in getImageThumbnail, or remove the declaration to fall back to checking each document's own mime type.\",\n )\n }\n }\n\n const access = incomingPluginConfig.access ?? (({ req }) => !!req.user)\n\n // The former function form was the health report's access gate. Accepting it\n // silently would widen that gate to the plugin's `access`, so it fails at boot.\n if (typeof incomingPluginConfig.healthCheck === 'function') {\n throw new Error(\n 'The alt-text plugin no longer accepts a function for `healthCheck`. ' +\n 'Move the access check to `healthCheck: { access: ({ req }) => ... }`.',\n )\n }\n\n const healthCheckConfig =\n typeof incomingPluginConfig.healthCheck === 'object' ? incomingPluginConfig.healthCheck : {}\n\n // The health report's own gate, falling back to the shared `access`.\n const healthCheckAccess = healthCheckConfig.access ?? access\n\n const pluginConfig: AltTextPluginConfig = {\n access,\n collections: normalizedCollections,\n enabled: incomingPluginConfig.enabled ?? true,\n fieldsOverride: incomingPluginConfig.fieldsOverride,\n filterLocales: incomingPluginConfig.filterLocales,\n getImageThumbnail: incomingPluginConfig.getImageThumbnail,\n healthCheck: enableHealthCheck,\n healthCheckAccess,\n healthCheckBaseFilter: healthCheckConfig.baseFilter,\n locale: incomingPluginConfig.locale,\n locales,\n maxBulkGenerateConcurrency: incomingPluginConfig.maxBulkGenerateConcurrency ?? 16,\n maxBulkGenerateIds: incomingPluginConfig.maxBulkGenerateIds ?? 100,\n resolver: incomingPluginConfig.resolver,\n }\n\n // Validate locale requirement for non-localized mode\n if (locales.length === 0 && !incomingPluginConfig.locale) {\n throw new Error(\n 'The alt-text plugin requires a \"locale\" option when Payload localization is disabled. ' +\n 'Please add { locale: \"en\" } (or your preferred locale) to your plugin configuration.',\n )\n }\n\n const collectionConfigBySlug = new Map<string, (typeof normalizedCollections)[number]>(\n normalizedCollections.map((entry) => [entry.slug, entry]),\n )\n\n // Collected while collections are mapped and flushed in `onInit`: no Payload instance —\n // and therefore no logger — exists while the config is still being built.\n const configWarnings: string[] = []\n\n // Ensure collections array exists\n config.collections = config.collections || []\n\n // Map over collections and inject AI alt text fields into specified ones\n config.collections = config.collections.map((collectionConfig) => {\n const altTextCollectionConfig = collectionConfigBySlug.get(collectionConfig.slug)\n\n if (altTextCollectionConfig) {\n if (!collectionConfig.upload) {\n configWarnings.push(\n `AI Alt Text Plugin: Collection \"${collectionConfig.slug}\" is not an upload collection. Skipping field injection.`,\n )\n return collectionConfig\n }\n\n const defaultFields = [\n altTextField({\n localized: Boolean(config.localization),\n // When the collection declares what getImageThumbnail delivers, the\n // document's own mime type says nothing about whether generation can\n // succeed — so don't let the admin UI disable the button on it.\n supportedMimeTypes: altTextCollectionConfig.imageThumbnailMimeType\n ? undefined\n : pluginConfig.resolver.supportedMimeTypes,\n trackedMimeTypes: altTextCollectionConfig.mimeTypes,\n validate: altTextCollectionConfig.validate,\n }),\n keywordsField({\n localized: Boolean(config.localization),\n }),\n ]\n\n const fields =\n incomingPluginConfig.fieldsOverride &&\n typeof incomingPluginConfig.fieldsOverride === 'function'\n ? incomingPluginConfig.fieldsOverride({ defaultFields })\n : defaultFields\n\n return {\n ...collectionConfig,\n admin: {\n ...collectionConfig.admin,\n components: {\n ...(collectionConfig.admin?.components ?? {}),\n // TODO: use the beforeBulkAction custom component slot once available: https://github.com/payloadcms/payload/pull/11719\n beforeListTable: [\n ...(collectionConfig.admin?.components?.beforeListTable ?? []),\n {\n path: '@jhb.software/payload-alt-text-plugin/client#BulkGenerateAltTextsButton',\n props: {\n collectionSlug: collectionConfig.slug,\n },\n },\n ],\n },\n // enhance the search by adding the filename, keywords and alt fields (if the user has not provided their own listSearchableFields)\n listSearchableFields: collectionConfig.admin?.listSearchableFields ?? [\n 'filename',\n 'keywords',\n 'alt',\n ],\n },\n fields: [...(collectionConfig.fields ?? []), ...fields],\n hooks: {\n ...collectionConfig.hooks,\n ...(enableHealthCheck && {\n afterChange: [\n ...(collectionConfig.hooks?.afterChange ?? []),\n createRevalidateAltTextHealthAfterChangeHook(collectionConfig.slug),\n ],\n afterDelete: [\n ...(collectionConfig.hooks?.afterDelete ?? []),\n createRevalidateAltTextHealthAfterDeleteHook(collectionConfig.slug),\n ],\n }),\n },\n }\n }\n\n return collectionConfig\n })\n\n const existingWidgets = config.admin?.dashboard?.widgets ?? []\n const widgets =\n !enableHealthCheck || existingWidgets.some((widget) => widget.slug === 'alt-text-health')\n ? existingWidgets\n : [...existingWidgets, altTextHealthWidgetDefinition]\n\n return {\n ...config,\n admin: {\n ...config.admin,\n dashboard: {\n ...config.admin?.dashboard,\n widgets,\n },\n },\n custom: {\n ...config.custom,\n // Make plugin config available in hooks/actions\n altTextPluginConfig: pluginConfig,\n },\n endpoints: [\n ...(config.endpoints ?? []),\n {\n handler: generateAltTextEndpoint(pluginConfig.access),\n method: 'post',\n path: `/${PLUGIN_SLUG}/generate`,\n },\n {\n handler: bulkGenerateAltTextsEndpoint(pluginConfig.access),\n method: 'post',\n path: `/${PLUGIN_SLUG}/generate/bulk`,\n },\n ...(enableHealthCheck\n ? [\n {\n handler: altTextHealthEndpoint(pluginConfig.healthCheckAccess),\n method: 'get' as const,\n path: `/${PLUGIN_SLUG}/health`,\n },\n ]\n : []),\n ],\n i18n: {\n ...config.i18n,\n translations: deepMergeSimple(translations, incomingConfig.i18n?.translations ?? {}),\n },\n onInit: async (payload) => {\n for (const warning of configWarnings) {\n payload.logger.warn(warning)\n }\n\n await config.onInit?.(payload)\n },\n }\n }\n"],"names":["PLUGIN_SLUG","altTextHealthEndpoint","bulkGenerateAltTextsEndpoint","generateAltTextEndpoint","altTextField","keywordsField","createRevalidateAltTextHealthAfterChangeHook","createRevalidateAltTextHealthAfterDeleteHook","translations","isValidMimeType","normalizeCollectionsConfig","deepMergeSimple","altTextHealthWidgetDefinition","slug","Component","ComponentPath","label","de","en","maxWidth","minWidth","payloadAltTextPlugin","incomingPluginConfig","incomingConfig","config","enabled","locales","localization","map","localeConfig","code","enableHealthCheck","healthCheck","normalizedCollections","collections","imageThumbnailMimeType","supportedMimeTypes","resolver","collection","declared","undefined","Error","includes","key","join","access","req","user","healthCheckConfig","healthCheckAccess","pluginConfig","fieldsOverride","filterLocales","getImageThumbnail","healthCheckBaseFilter","baseFilter","locale","maxBulkGenerateConcurrency","maxBulkGenerateIds","length","collectionConfigBySlug","Map","entry","configWarnings","collectionConfig","altTextCollectionConfig","get","upload","push","defaultFields","localized","Boolean","trackedMimeTypes","mimeTypes","validate","fields","admin","components","beforeListTable","path","props","collectionSlug","listSearchableFields","hooks","afterChange","afterDelete","existingWidgets","dashboard","widgets","some","widget","custom","altTextPluginConfig","endpoints","handler","method","i18n","onInit","payload","warning","logger","warn"],"mappings":"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"}
|
|
@@ -47,10 +47,9 @@ export type AnthropicResolverConfig = {
|
|
|
47
47
|
*
|
|
48
48
|
* The image is downloaded and sent as bytes. Claude can fetch an image URL
|
|
49
49
|
* itself, but that path is not dependable for a CMS: it requires the file to be
|
|
50
|
-
* reachable from the public internet
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* that a base64 image block requires and a URL cannot carry.
|
|
50
|
+
* reachable from the public internet. Sending the bytes removes that whole class
|
|
51
|
+
* of failure for the price of one extra download. The `media_type` a base64 image block carries
|
|
52
|
+
* comes from the download, which reads it off what the thumbnail URL served.
|
|
54
53
|
*
|
|
55
54
|
* @example
|
|
56
55
|
* ```typescript
|
|
@@ -30,10 +30,9 @@ import { createVisionResolver, VisionProviderError } from './createVisionResolve
|
|
|
30
30
|
*
|
|
31
31
|
* The image is downloaded and sent as bytes. Claude can fetch an image URL
|
|
32
32
|
* itself, but that path is not dependable for a CMS: it requires the file to be
|
|
33
|
-
* reachable from the public internet
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* that a base64 image block requires and a URL cannot carry.
|
|
33
|
+
* reachable from the public internet. Sending the bytes removes that whole class
|
|
34
|
+
* of failure for the price of one extra download. The `media_type` a base64 image block carries
|
|
35
|
+
* comes from the download, which reads it off what the thumbnail URL served.
|
|
37
36
|
*
|
|
38
37
|
* @example
|
|
39
38
|
* ```typescript
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/resolvers/anthropic.ts"],"sourcesContent":["import type { VisionInstructions } from './createVisionResolver.js'\nimport type { AltTextResolver } from './types.js'\n\nimport { createVisionResolver, VisionProviderError } from './createVisionResolver.js'\n\nexport type AnthropicResolverConfig = {\n /** Anthropic API key for authentication */\n apiKey: string\n /**\n * Base URL of the Anthropic API.\n * @default 'https://api.anthropic.com'\n */\n baseUrl?: string\n /**\n * Caps how long Claude thinks before answering. Lower effort still thinks on a\n * difficult image, just less than a higher setting would.\n *\n * Describing an image is not a reasoning-heavy task, so `'low'` keeps the\n * spend down on the models that accept it. Omitted, the field is not sent and\n * Claude uses its default (`'high'`) — which also keeps models without effort\n * support, such as `claude-haiku-4-5`, usable.\n */\n effort?: 'high' | 'low' | 'max' | 'medium' | 'xhigh'\n /**\n * Builds the instructions from the default ones, e.g. to append a house style\n * rule. Sent as the system prompt, separately from the image.\n *\n * @default ({ defaultInstructions }) => defaultInstructions\n */\n instructions?: VisionInstructions\n /**\n * The Claude model to use for alt text generation.\n *\n * Must be able to read images. `claude-sonnet-5` is the cheaper choice for a\n * large media library; `claude-haiku-4-5` works too, but only without\n * `effort`.\n *\n * @default 'claude-opus-5'\n */\n model?: string\n /**\n * Abort after this many milliseconds. Covers downloading the image and the\n * message call together.\n * @default 30000\n */\n timeoutMs?: number\n}\n\n/**\n * Image formats the Messages 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://platform.claude.com/docs/en/build-with-claude/vision\n */\nconst SUPPORTED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']\n\n/**\n * Claude's 10 MB ceiling is measured on the base64 payload, which inflates the\n * raw bytes by roughly 4/3 — so the guard has to sit at ~7.5 MB of raw image to\n * mean 10 MB on the wire. Checking the raw length against 10 MB would wave\n * through a 9 MB photo that arrives as ~12 MB and is rejected by the provider,\n * costing the download and replacing a readable message with a raw 400.\n */\nconst MAX_IMAGE_BYTES = Math.floor((10 * 1024 * 1024 * 3) / 4)\n\n/**\n * Room for one alt text and its keywords per locale, plus the thinking tokens\n * Claude spends before answering. A budget sized for the answer alone would be\n * exhausted while reasoning, and the response would be cut off mid-JSON.\n */\nconst MAX_TOKENS_PER_LOCALE = 2000\n\ntype AnthropicMessage = {\n content?: { text?: string; type: string }[]\n stop_reason?: string\n}\n\n/**\n * Creates a Claude-based resolver for alt text generation.\n *\n * The image is downloaded and sent as bytes. Claude can fetch an image URL\n * itself, but that path is not dependable for a CMS: it requires the file to be\n * reachable from the public internet
|
|
1
|
+
{"version":3,"sources":["../../src/resolvers/anthropic.ts"],"sourcesContent":["import type { VisionInstructions } from './createVisionResolver.js'\nimport type { AltTextResolver } from './types.js'\n\nimport { createVisionResolver, VisionProviderError } from './createVisionResolver.js'\n\nexport type AnthropicResolverConfig = {\n /** Anthropic API key for authentication */\n apiKey: string\n /**\n * Base URL of the Anthropic API.\n * @default 'https://api.anthropic.com'\n */\n baseUrl?: string\n /**\n * Caps how long Claude thinks before answering. Lower effort still thinks on a\n * difficult image, just less than a higher setting would.\n *\n * Describing an image is not a reasoning-heavy task, so `'low'` keeps the\n * spend down on the models that accept it. Omitted, the field is not sent and\n * Claude uses its default (`'high'`) — which also keeps models without effort\n * support, such as `claude-haiku-4-5`, usable.\n */\n effort?: 'high' | 'low' | 'max' | 'medium' | 'xhigh'\n /**\n * Builds the instructions from the default ones, e.g. to append a house style\n * rule. Sent as the system prompt, separately from the image.\n *\n * @default ({ defaultInstructions }) => defaultInstructions\n */\n instructions?: VisionInstructions\n /**\n * The Claude model to use for alt text generation.\n *\n * Must be able to read images. `claude-sonnet-5` is the cheaper choice for a\n * large media library; `claude-haiku-4-5` works too, but only without\n * `effort`.\n *\n * @default 'claude-opus-5'\n */\n model?: string\n /**\n * Abort after this many milliseconds. Covers downloading the image and the\n * message call together.\n * @default 30000\n */\n timeoutMs?: number\n}\n\n/**\n * Image formats the Messages 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://platform.claude.com/docs/en/build-with-claude/vision\n */\nconst SUPPORTED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']\n\n/**\n * Claude's 10 MB ceiling is measured on the base64 payload, which inflates the\n * raw bytes by roughly 4/3 — so the guard has to sit at ~7.5 MB of raw image to\n * mean 10 MB on the wire. Checking the raw length against 10 MB would wave\n * through a 9 MB photo that arrives as ~12 MB and is rejected by the provider,\n * costing the download and replacing a readable message with a raw 400.\n */\nconst MAX_IMAGE_BYTES = Math.floor((10 * 1024 * 1024 * 3) / 4)\n\n/**\n * Room for one alt text and its keywords per locale, plus the thinking tokens\n * Claude spends before answering. A budget sized for the answer alone would be\n * exhausted while reasoning, and the response would be cut off mid-JSON.\n */\nconst MAX_TOKENS_PER_LOCALE = 2000\n\ntype AnthropicMessage = {\n content?: { text?: string; type: string }[]\n stop_reason?: string\n}\n\n/**\n * Creates a Claude-based resolver for alt text generation.\n *\n * The image is downloaded and sent as bytes. Claude can fetch an image URL\n * itself, but that path is not dependable for a CMS: it requires the file to be\n * reachable from the public internet. Sending the bytes removes that whole class\n * of failure for the price of one extra download. The `media_type` a base64 image block carries\n * comes from the download, which reads it off what the thumbnail URL served.\n *\n * @example\n * ```typescript\n * import { anthropicResolver } from '@jhb.software/payload-alt-text-plugin'\n *\n * anthropicResolver({\n * apiKey: process.env.ANTHROPIC_API_KEY,\n * model: 'claude-opus-5', // optional, this is the default\n * })\n * ```\n */\nexport const anthropicResolver = ({\n apiKey,\n baseUrl = 'https://api.anthropic.com',\n effort,\n instructions,\n model = 'claude-opus-5',\n timeoutMs = 30_000,\n}: AnthropicResolverConfig): 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}/v1/messages`, {\n body: JSON.stringify({\n max_tokens: maxTokens,\n messages: [\n {\n content: [\n // Claude works best when the image comes before the text.\n {\n type: 'image',\n source: { type: 'base64', data: image.base64, media_type: image.mediaType },\n },\n ...(filename ? [{ type: 'text', text: filename }] : []),\n ],\n role: 'user',\n },\n ],\n model,\n // `format` constrains the response to the schema the plugin needs;\n // `effort` caps how long Claude thinks before producing it. Only sent\n // when configured: some models reject the field outright.\n output_config: {\n ...(effort ? { effort } : {}),\n format: { type: 'json_schema', schema: responseSchema },\n },\n // The instructions are an operator instruction, not a turn in the\n // conversation, so they travel as the top-level system prompt.\n system: resolvedInstructions,\n }),\n headers: {\n 'anthropic-version': '2023-06-01',\n 'content-type': 'application/json',\n 'x-api-key': apiKey,\n },\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: 'Anthropic', status: response.status })\n }\n\n const message = (await response.json()) as AnthropicMessage\n\n // A refusal and a truncated answer both arrive as a 200 with unusable\n // content, so they are named rather than surfacing as a JSON parse error.\n if (message.stop_reason === 'refusal') {\n throw new Error('Claude declined to describe this image')\n }\n\n if (message.stop_reason === 'max_tokens') {\n throw new Error(\n `Claude ran out of tokens before finishing the alt text (max_tokens: ${maxTokens})`,\n )\n }\n\n const text = message.content?.find((block) => block.type === 'text')?.text\n\n if (typeof text !== 'string') {\n throw new Error('No result from Anthropic')\n }\n\n try {\n return JSON.parse(text)\n } catch {\n throw new Error('Claude returned a response that was not valid JSON')\n }\n },\n inlineImage: true,\n instructions,\n key: 'anthropic',\n label: 'Anthropic',\n maxImageBytes: MAX_IMAGE_BYTES,\n maxTokensPerLocale: MAX_TOKENS_PER_LOCALE,\n supportedMimeTypes: SUPPORTED_MIME_TYPES,\n timeoutMs,\n })\n"],"names":["createVisionResolver","VisionProviderError","SUPPORTED_MIME_TYPES","MAX_IMAGE_BYTES","Math","floor","MAX_TOKENS_PER_LOCALE","anthropicResolver","apiKey","baseUrl","effort","instructions","model","timeoutMs","generate","filename","image","resolvedInstructions","maxTokens","responseSchema","signal","Error","response","fetch","body","JSON","stringify","max_tokens","messages","content","type","source","data","base64","media_type","mediaType","text","role","output_config","format","schema","system","headers","method","ok","catch","slice","label","status","message","json","stop_reason","find","block","parse","inlineImage","key","maxImageBytes","maxTokensPerLocale","supportedMimeTypes"],"mappings":"AAGA,SAASA,oBAAoB,EAAEC,mBAAmB,QAAQ,4BAA2B;AA6CrF;;;;;;;;CAQC,GACD,MAAMC,uBAAuB;IAAC;IAAc;IAAa;IAAa;CAAa;AAEnF;;;;;;CAMC,GACD,MAAMC,kBAAkBC,KAAKC,KAAK,CAAC,AAAC,KAAK,OAAO,OAAO,IAAK;AAE5D;;;;CAIC,GACD,MAAMC,wBAAwB;AAO9B;;;;;;;;;;;;;;;;;;CAkBC,GACD,OAAO,MAAMC,oBAAoB,CAAC,EAChCC,MAAM,EACNC,UAAU,2BAA2B,EACrCC,MAAM,EACNC,YAAY,EACZC,QAAQ,eAAe,EACvBC,YAAY,MAAM,EACM,GACxBb,qBAAqB;QACnBQ;QACAM,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,GAAGd,QAAQ,YAAY,CAAC,EAAE;gBACrDe,MAAMC,KAAKC,SAAS,CAAC;oBACnBC,YAAYT;oBACZU,UAAU;wBACR;4BACEC,SAAS;gCACP,0DAA0D;gCAC1D;oCACEC,MAAM;oCACNC,QAAQ;wCAAED,MAAM;wCAAUE,MAAMhB,MAAMiB,MAAM;wCAAEC,YAAYlB,MAAMmB,SAAS;oCAAC;gCAC5E;mCACIpB,WAAW;oCAAC;wCAAEe,MAAM;wCAAQM,MAAMrB;oCAAS;iCAAE,GAAG,EAAE;6BACvD;4BACDsB,MAAM;wBACR;qBACD;oBACDzB;oBACA,mEAAmE;oBACnE,sEAAsE;oBACtE,0DAA0D;oBAC1D0B,eAAe;wBACb,GAAI5B,SAAS;4BAAEA;wBAAO,IAAI,CAAC,CAAC;wBAC5B6B,QAAQ;4BAAET,MAAM;4BAAeU,QAAQrB;wBAAe;oBACxD;oBACA,kEAAkE;oBAClE,+DAA+D;oBAC/DsB,QAAQxB;gBACV;gBACAyB,SAAS;oBACP,qBAAqB;oBACrB,gBAAgB;oBAChB,aAAalC;gBACf;gBACAmC,QAAQ;gBACRvB;YACF;YAEA,IAAI,CAACE,SAASsB,EAAE,EAAE;gBAChB,gEAAgE;gBAChE,MAAMpB,OAAO,AAAC,CAAA,MAAMF,SAASc,IAAI,GAAGS,KAAK,CAAC,IAAM,GAAE,EAAGC,KAAK,CAAC,GAAG;gBAE9D,MAAM,IAAI7C,oBAAoB;oBAAEuB;oBAAMuB,OAAO;oBAAaC,QAAQ1B,SAAS0B,MAAM;gBAAC;YACpF;YAEA,MAAMC,UAAW,MAAM3B,SAAS4B,IAAI;YAEpC,sEAAsE;YACtE,0EAA0E;YAC1E,IAAID,QAAQE,WAAW,KAAK,WAAW;gBACrC,MAAM,IAAI9B,MAAM;YAClB;YAEA,IAAI4B,QAAQE,WAAW,KAAK,cAAc;gBACxC,MAAM,IAAI9B,MACR,CAAC,oEAAoE,EAAEH,UAAU,CAAC,CAAC;YAEvF;YAEA,MAAMkB,OAAOa,QAAQpB,OAAO,EAAEuB,KAAK,CAACC,QAAUA,MAAMvB,IAAI,KAAK,SAASM;YAEtE,IAAI,OAAOA,SAAS,UAAU;gBAC5B,MAAM,IAAIf,MAAM;YAClB;YAEA,IAAI;gBACF,OAAOI,KAAK6B,KAAK,CAAClB;YACpB,EAAE,OAAM;gBACN,MAAM,IAAIf,MAAM;YAClB;QACF;QACAkC,aAAa;QACb5C;QACA6C,KAAK;QACLT,OAAO;QACPU,eAAetD;QACfuD,oBAAoBpD;QACpBqD,oBAAoBzD;QACpBW;IACF,GAAE"}
|
|
@@ -92,7 +92,7 @@ export type VisionResolverConfig = {
|
|
|
92
92
|
* Download the thumbnail and hand `generate` the bytes rather than the URL.
|
|
93
93
|
*
|
|
94
94
|
* Needed by every provider whose own fetcher requires a publicly reachable
|
|
95
|
-
* file
|
|
95
|
+
* file.
|
|
96
96
|
*/
|
|
97
97
|
inlineImage?: boolean;
|
|
98
98
|
/**
|
|
@@ -1 +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":"AAEA,SAASA,CAAC,QAAQ,MAAK;AA+DvB;;;;;;;;;;;;;CAaC,GACD,OAAO,MAAMC,4BAA4BC;IACvC,0EAA0E,GAC1E,AAASC,KAAa;IACbC,OAAc;IAEvB,YAAY,EAAED,IAAI,EAAEE,KAAK,EAAED,MAAM,EAAoD,CAAE;QACrF,KAAK,CAAC,GAAGC,MAAM,uBAAuB,EAAED,QAAQ;QAChD,IAAI,CAACD,IAAI,GAAGA;QACZ,IAAI,CAACG,IAAI,GAAG;QACZ,IAAI,CAACF,MAAM,GAAGA;IAChB;IAEA,sEAAsE,GACtE,IAAIG,cAAuB;QACzB,OAAO,IAAI,CAACH,MAAM,KAAK,OAAO,IAAI,CAACA,MAAM,IAAI;IAC/C;AACF;AAEA,6EAA6E,GAC7E,MAAMI,cAAc;AAEpB,wEAAwE,GACxE,MAAMC,eAAe,CAACC,UAAoB,MAAM,KAAMA,CAAAA,UAAU,CAAA;AAwDhE,MAAMC,gBAAgBX,EAAEY,MAAM,CAAC;IAC7BC,SAASb,EAAEc,MAAM,GAAGC,QAAQ,CAAC;IAC7BC,UAAUhB,EAAEiB,KAAK,CAACjB,EAAEc,MAAM,IAAIC,QAAQ,CAAC;AACzC;AAEA,qFAAqF,GACrF,OAAO,MAAMG,mBAAmB,CAACC,UAC/BnB,EAAEY,MAAM,CAACQ,OAAOC,WAAW,CAACF,QAAQG,GAAG,CAAC,CAACC,SAAW;YAACA;YAAQZ;SAAc,IAAG;AAEhF;;;CAGC,GACD,MAAMa,2BAA2B,CAAC,EAAEL,OAAO,EAAyB,GAClE;QACE,CAAC,8EAA8E,CAAC;QAEhF,CAAC,4DAA4D,EAAEA,QAAQM,IAAI,CAAC,MAAM,CAAC,CAAC;QAEpF,CAAC,0RAA0R,CAAC;QAE5R,CAAC,gHAAgH,CAAC;QAElH,CAAC,yDAAyD,CAAC;QAE3D,CAAC,2CAA2C,EAAEN,QAAQG,GAAG,CAAC,CAACC,SAAW,CAAC,CAAC,EAAEA,OAAO,CAAC,CAAC,EAAEE,IAAI,CAAC,MAAM,gDAAgD,CAAC;KAClJ,CAACA,IAAI,CAAC;AAET;;;;;;;;;CASC,GACD,eAAeC,WAAW,EACxBC,iBAAiB,EACjBtB,KAAK,EACLuB,aAAa,EACbC,MAAM,EACNC,kBAAkB,EAClBC,GAAG,EAQJ;IACC,IAAIC;IAEJ,IAAI;QACFA,WAAW,MAAMC,MAAMF,KAAK;YAAEF;QAAO;IACvC,EAAE,OAAOK,OAAO;QACd,OAAO;YACLA,OAAO,CAAC,kCAAkC,EAAEH,IAAI,EAAE,EAAEG,iBAAiBhC,QAAQgC,MAAMC,OAAO,GAAG,iBAAiB;QAChH;IACF;IAEA,IAAI,CAACH,SAASI,EAAE,EAAE;QAChB,OAAO;YAAEF,OAAO,CAAC,kCAAkC,EAAEH,IAAI,SAAS,EAAEC,SAAS5B,MAAM,EAAE;QAAC;IACxF;IAEA,MAAMiC,SAASL,SAASM,OAAO,CAACC,GAAG,CAAC,iBAAiBC,MAAM,IAAI,CAAC,EAAE,EAAEC,OAAOC;IAC3E,MAAMC,YACJN,UAAUA,WAAW,6BAA6BA,SAASV,mBAAmBe;IAEhF,IAAI,CAACC,WAAW;QACd,OAAO;YACLT,OAAO,CAAC,aAAa,EAAEH,IAAI,eAAe,EAAEM,SAAS,CAAC,CAAC,EAAEA,OAAO,CAAC,CAAC,GAAG,yBAAyB,4FAA4F,EAAEhC,MAAM,0BAA0B,CAAC;QAC/N;IACF;IAEA,IAAIyB,sBAAsB,CAACA,mBAAmBc,QAAQ,CAACD,YAAY;QACjE,OAAO;YACLT,OAAO,CAAC,aAAa,EAAEH,IAAI,gBAAgB,EAAEY,UAAU,SAAS,EAAEtC,MAAM,+BAA+B,EAAEyB,mBAAmBL,IAAI,CAAC,MAAM,CAAC,CAAC;QAC3I;IACF;IAEA,MAAMoB,WAAW,CAACC,aAChB,CAAC,aAAa,EAAEf,IAAI,IAAI,EAAEgB,KAAKC,KAAK,CAACF,aAAa,OAAO,MAAM,WAAW,EAAEzC,MAAM,GAAG,EAAE0C,KAAKC,KAAK,CAACpB,gBAAgB,OAAO,MAAM,2DAA2D,CAAC;IAE7L,8EAA8E;IAC9E,2EAA2E;IAC3E,uDAAuD;IACvD,MAAMqB,iBAAiBC,OAAOlB,SAASM,OAAO,CAACC,GAAG,CAAC;IAEnD,IAAIW,OAAOC,SAAS,CAACF,mBAAmBA,iBAAiBrB,eAAe;QACtE,OAAO;YAAEM,OAAOW,SAASI;QAAgB;IAC3C;IAEA,MAAMG,QAAQC,OAAOC,IAAI,CAAC,MAAMtB,SAASuB,WAAW;IAEpD,IAAIH,MAAMN,UAAU,KAAK,GAAG;QAC1B,OAAO;YAAEZ,OAAO,CAAC,aAAa,EAAEH,IAAI,WAAW,CAAC;QAAC;IACnD;IAEA,IAAIqB,MAAMN,UAAU,GAAGlB,eAAe;QACpC,OAAO;YAAEM,OAAOW,SAASO,MAAMN,UAAU;QAAE;IAC7C;IAEA,MAAMU,SAASJ,MAAMK,QAAQ,CAAC;IAE9B,OAAO;QAAEC,OAAO;YAAEF;YAAQG,SAAS,CAAC,KAAK,EAAEhB,UAAU,QAAQ,EAAEa,QAAQ;YAAEb;QAAU;IAAE;AACvF;AAEA;;;;;;;;CAQC,GACD,eAAeiB,kBAAkB,EAC/BC,IAAI,EACJC,QAAQ,EAIT;IACC,IAAK,IAAIpD,UAAU,IAAKA,UAAW;QACjC,IAAI;YACF,OAAO,MAAMoD,SAASD;QACxB,EAAE,OAAO3B,OAAO;YACd,MAAM6B,cAAc7B,iBAAiBjC,uBAAuBiC,MAAM3B,WAAW;YAE7E,IAAI,CAACwD,eAAerD,WAAWF,eAAeqD,KAAKhC,MAAM,EAAEmC,SAAS;gBAClE,MAAM9B;YACR;YAEA,MAAM,IAAI+B,QAAQ,CAACC,UAAYC,WAAWD,SAASzD,aAAaC,UAAU;QAC5E;IACF;AACF;AAEA;;;;;;CAMC,GACD,SAAS0D,aAAaC,OAAgB,EAAElD,OAAiB;IACvD,MAAMmD,SAASpD,iBAAiBC,SAASoD,SAAS,CAACF;IAEnD,IAAI,CAACC,OAAOE,OAAO,EAAE;QACnB,OAAO;IACT;IAEA,MAAMC,UAAyC,CAAC;IAEhD,KAAK,MAAMlD,UAAUJ,QAAS;QAC5B,MAAMuD,QAAQJ,OAAOK,IAAI,CAACpD,OAAO;QAEjC,IAAImD,MAAM7D,OAAO,CAAC4B,IAAI,GAAGmC,MAAM,KAAK,GAAG;YACrC,OAAO;QACT;QAEAH,OAAO,CAAClD,OAAO,GAAG;YAAEV,SAAS6D,MAAM7D,OAAO,CAAC4B,IAAI;YAAIzB,UAAU0D,MAAM1D,QAAQ;QAAC;IAC9E;IAEA,OAAOyD;AACT;AAEA;;;;;;;;;CASC,GACD,OAAO,MAAMI,uBAAuB,CAAC,EACnCC,MAAM,EACNhB,QAAQ,EACRiB,cAAc,KAAK,EACnBC,eAAe,CAAC,EAAEC,mBAAmB,EAAE,GAAKA,mBAAmB,EAC/DC,GAAG,EACH7E,KAAK,EACLuB,gBAAgB,KAAK,OAAO,IAAI,EAChCuD,qBAAqB,GAAG,EACxBrD,kBAAkB,EAClBsD,SAAS,EACY;IACrB,MAAMC,MAAM,OAAO,EACjBC,QAAQ,EACRC,sBAAsB,EACtBC,iBAAiB,EACjBrE,OAAO,EACPsE,GAAG,EAOJ;QAGC,IAAI,CAACX,QAAQ;YACX,OAAO;gBAAE5C,OAAO,CAAC,GAAG,EAAE7B,MAAM,mBAAmB,CAAC;gBAAEmE,SAAS;YAAM;QACnE;QAEA,IAAIrD,QAAQyD,MAAM,KAAK,GAAG;YACxB,OAAO;gBAAE1C,OAAO;gBAAuBsC,SAAS;YAAM;QACxD;QAEA,MAAM3C,SAASuD,cAAcM,YAAYA,YAAYC,YAAYC,OAAO,CAACR;QAEzE,IAAI1B;QAEJ,IAAIqB,aAAa;YACf,MAAMc,aAAa,MAAMnE,WAAW;gBAClCC,mBAAmB4D;gBACnBlF;gBACAuB;gBACAC;gBACAC;gBACAC,KAAKyD;YACP;YAEA,IAAI,WAAWK,YAAY;gBACzB,OAAO;oBAAE3D,OAAO2D,WAAW3D,KAAK;oBAAEsC,SAAS;gBAAM;YACnD;YAEAd,QAAQmC,WAAWnC,KAAK;QAC1B;QAEA,IAAI;YACF,MAAMuB,sBAAsBzD,yBAAyB;gBAAEL;YAAQ;YAE/D,MAAMkD,UAAU,MAAMT,kBAAkB;gBACtCC,MAAM;oBACJyB;oBACA5B;oBACA6B;oBACAC;oBACAR,cAAc,MAAMA,aAAa;wBAAEC;wBAAqBK;wBAAUnE;oBAAQ;oBAC1EA;oBACA2E,WAAWX,qBAAqBhE,QAAQyD,MAAM;oBAC9Ca;oBACAM,gBAAgB/F,EAAEgG,YAAY,CAAC9E,iBAAiBC,UAAU;wBAAE8E,QAAQ;oBAAU;oBAC9EpE;gBACF;gBACAiC;YACF;YAEA,MAAMW,UAAUL,aAAaC,SAASlD;YAEtC,IAAI,CAACsD,SAAS;gBACZ,OAAO;oBACLvC,OAAO,GAAG7B,MAAM,8DAA8D,EAAEc,QAAQM,IAAI,CAAC,MAAM,CAAC,CAAC;oBACrG+C,SAAS;gBACX;YACF;YAEA,OAAO;gBAAEC;gBAASD,SAAS;YAAK;QAClC,EAAE,OAAOtC,OAAO;YACduD,IAAIS,OAAO,CAACC,MAAM,CAACjE,KAAK,CAAC;gBACvBkE,KAAKlE;gBACLmE,KAAK;gBACL,sEAAsE;gBACtE,2EAA2E;gBAC3EC,kBAAkBpE,iBAAiBjC,sBAAsBiC,MAAM/B,IAAI,GAAGuF;gBACtEa,UAAUrB;YACZ;YAEA,OAAO;gBAAEhD,OAAOA,iBAAiBhC,QAAQgC,MAAMC,OAAO,GAAG;gBAAiBqC,SAAS;YAAM;QAC3F;IACF;IAEA,OAAO;QACLU;QACAhB,SAAS,OAAO,EACdoB,QAAQ,EACRC,sBAAsB,EACtBC,iBAAiB,EACjBjE,MAAM,EACNkE,GAAG,EACiB;YACpB,MAAMe,SAAS,MAAMnB,IAAI;gBACvBC;gBACAC;gBACAC;gBACArE,SAAS;oBAACI;iBAAO;gBACjBkE;YACF;YAEA,IAAI,CAACe,OAAOhC,OAAO,EAAE;gBACnB,OAAO;oBAAEtC,OAAOsE,OAAOtE,KAAK;oBAAEsC,SAAS;gBAAM;YAC/C;YAEA,OAAO;gBAAEgC,QAAQA,OAAO/B,OAAO,CAAClD,OAAO;gBAAEiD,SAAS;YAAK;QACzD;QACAiC,aAAa,OAAO,EAClBnB,QAAQ,EACRC,sBAAsB,EACtBC,iBAAiB,EACjBrE,OAAO,EACPsE,GAAG,EACqB;YACxB,MAAMe,SAAS,MAAMnB,IAAI;gBACvBC;gBACAC;gBACAC;gBACArE;gBACAsE;YACF;YAEA,IAAI,CAACe,OAAOhC,OAAO,EAAE;gBACnB,OAAO;oBAAEtC,OAAOsE,OAAOtE,KAAK;oBAAEsC,SAAS;gBAAM;YAC/C;YAEA,OAAO;gBAAEC,SAAS+B,OAAO/B,OAAO;gBAAED,SAAS;YAAK;QAClD;QACA1C;IACF;AACF,EAAC"}
|
|
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.\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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"}
|
|
@@ -36,9 +36,10 @@ export type MistralResolverConfig = {
|
|
|
36
36
|
* Creates a Mistral-based resolver for alt text generation.
|
|
37
37
|
*
|
|
38
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
|
|
40
|
-
*
|
|
41
|
-
*
|
|
39
|
+
* URL. Mistral's own fetcher requires a publicly reachable file, and even that is
|
|
40
|
+
* not sufficient: hosts serving the image fine to everyone else were observed
|
|
41
|
+
* refusing it with `File could not be fetched from url` (error 3310). See #184
|
|
42
|
+
* for the reproduction.
|
|
42
43
|
*
|
|
43
44
|
* @example
|
|
44
45
|
* ```typescript
|
|
@@ -17,9 +17,10 @@ import { createVisionResolver, VisionProviderError } from './createVisionResolve
|
|
|
17
17
|
* Creates a Mistral-based resolver for alt text generation.
|
|
18
18
|
*
|
|
19
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
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* URL. Mistral's own fetcher requires a publicly reachable file, and even that is
|
|
21
|
+
* not sufficient: hosts serving the image fine to everyone else were observed
|
|
22
|
+
* refusing it with `File could not be fetched from url` (error 3310). See #184
|
|
23
|
+
* for the reproduction.
|
|
23
24
|
*
|
|
24
25
|
* @example
|
|
25
26
|
* ```typescript
|
|
@@ -1 +1 @@
|
|
|
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
|
|
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, and even that is\n * not sufficient: hosts serving the image fine to everyone else were observed\n * refusing it with `File could not be fetched from url` (error 3310). See #184\n * for the reproduction.\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;;;;;;;;;;;;;;;;;;CAkBC,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"}
|
|
@@ -42,9 +42,9 @@ export type OpenAIResolverConfig = {
|
|
|
42
42
|
* Creates an OpenAI-based resolver for alt text generation.
|
|
43
43
|
*
|
|
44
44
|
* The thumbnail URL is handed to OpenAI, which fetches it itself — so the URL
|
|
45
|
-
* has to be reachable from the public internet.
|
|
46
|
-
*
|
|
47
|
-
*
|
|
45
|
+
* has to be reachable from the public internet. When it is not, reach for a
|
|
46
|
+
* resolver that inlines the bytes instead (`mistralResolver`,
|
|
47
|
+
* `anthropicResolver`).
|
|
48
48
|
*
|
|
49
49
|
* @example
|
|
50
50
|
* ```typescript
|
package/dist/resolvers/openAI.js
CHANGED
|
@@ -9,9 +9,9 @@ import { createVisionResolver, VisionProviderError } from './createVisionResolve
|
|
|
9
9
|
* Creates an OpenAI-based resolver for alt text generation.
|
|
10
10
|
*
|
|
11
11
|
* The thumbnail URL is handed to OpenAI, which fetches it itself — so the URL
|
|
12
|
-
* has to be reachable from the public internet.
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* has to be reachable from the public internet. When it is not, reach for a
|
|
13
|
+
* resolver that inlines the bytes instead (`mistralResolver`,
|
|
14
|
+
* `anthropicResolver`).
|
|
15
15
|
*
|
|
16
16
|
* @example
|
|
17
17
|
* ```typescript
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/resolvers/openAI.ts"],"sourcesContent":["import type { VisionInstructions } from './createVisionResolver.js'\nimport type { AltTextResolver } from './types.js'\n\nimport { createVisionResolver, VisionProviderError } from './createVisionResolver.js'\n\nexport type OpenAIResolverConfig = {\n /** OpenAI API key for authentication */\n apiKey: string\n /**\n * Base URL for the OpenAI-compatible API, including the version segment.\n * Use this to point at alternative providers (e.g. Azure, Nebius, local inference).\n * @default 'https://api.openai.com/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 OpenAI LLM model to use for alt text generation.\n * @default 'gpt-4.1-nano'\n */\n model?: string\n /**\n * The MIME types the provider accepts for the image URL.\n *\n * Defaults to the formats documented for OpenAI's vision models. Override it\n * when pointing `baseUrl` at another provider whose accepted formats differ —\n * the person choosing the provider is the one who knows.\n *\n * @default ['image/jpeg', 'image/png', 'image/gif', 'image/webp']\n */\n supportedMimeTypes?: string[]\n /**\n * Abort after this many milliseconds, covering the completion call and the\n * retries the factory makes within it.\n * @default 30000\n */\n timeoutMs?: number\n}\n\n/** @see https://platform.openai.com/docs/guides/images-vision */\nconst OPENAI_SUPPORTED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']\n\n/**\n * Creates an OpenAI-based resolver for alt text generation.\n *\n * The thumbnail URL is handed to OpenAI, which fetches it itself — so the URL\n * has to be reachable from the public internet.
|
|
1
|
+
{"version":3,"sources":["../../src/resolvers/openAI.ts"],"sourcesContent":["import type { VisionInstructions } from './createVisionResolver.js'\nimport type { AltTextResolver } from './types.js'\n\nimport { createVisionResolver, VisionProviderError } from './createVisionResolver.js'\n\nexport type OpenAIResolverConfig = {\n /** OpenAI API key for authentication */\n apiKey: string\n /**\n * Base URL for the OpenAI-compatible API, including the version segment.\n * Use this to point at alternative providers (e.g. Azure, Nebius, local inference).\n * @default 'https://api.openai.com/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 OpenAI LLM model to use for alt text generation.\n * @default 'gpt-4.1-nano'\n */\n model?: string\n /**\n * The MIME types the provider accepts for the image URL.\n *\n * Defaults to the formats documented for OpenAI's vision models. Override it\n * when pointing `baseUrl` at another provider whose accepted formats differ —\n * the person choosing the provider is the one who knows.\n *\n * @default ['image/jpeg', 'image/png', 'image/gif', 'image/webp']\n */\n supportedMimeTypes?: string[]\n /**\n * Abort after this many milliseconds, covering the completion call and the\n * retries the factory makes within it.\n * @default 30000\n */\n timeoutMs?: number\n}\n\n/** @see https://platform.openai.com/docs/guides/images-vision */\nconst OPENAI_SUPPORTED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']\n\n/**\n * Creates an OpenAI-based resolver for alt text generation.\n *\n * The thumbnail URL is handed to OpenAI, which fetches it itself — so the URL\n * has to be reachable from the public internet. When it is not, reach for a\n * resolver that inlines the bytes instead (`mistralResolver`,\n * `anthropicResolver`).\n *\n * @example\n * ```typescript\n * import { openAIResolver } from '@jhb.software/payload-alt-text-plugin'\n *\n * // OpenAI\n * openAIResolver({\n * apiKey: process.env.OPENAI_API_KEY,\n * model: 'gpt-4.1-mini', // optional, defaults to 'gpt-4.1-nano'\n * })\n *\n * // OpenAI-compatible provider (e.g. Nebius)\n * openAIResolver({\n * apiKey: process.env.NEBIUS_API_KEY,\n * baseUrl: 'https://api.tokenfactory.us-central1.nebius.com/v1',\n * model: 'Qwen/Qwen2.5-VL-72B-Instruct',\n * })\n * ```\n */\nexport const openAIResolver = ({\n apiKey,\n baseUrl = 'https://api.openai.com/v1',\n instructions,\n model = 'gpt-4.1-nano',\n supportedMimeTypes = OPENAI_SUPPORTED_MIME_TYPES,\n timeoutMs = 30_000,\n}: OpenAIResolverConfig): AltTextResolver =>\n createVisionResolver({\n apiKey,\n generate: async ({\n filename,\n imageThumbnailUrl,\n instructions: resolvedInstructions,\n maxTokens,\n responseSchema,\n signal,\n }) => {\n const response = await fetch(`${baseUrl}/chat/completions`, {\n body: JSON.stringify({\n max_completion_tokens: maxTokens,\n messages: [\n { content: resolvedInstructions, role: 'system' },\n {\n content: [\n { type: 'image_url', image_url: { url: imageThumbnailUrl } },\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: 'OpenAI', 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 // A budget exhausted mid-JSON otherwise reaches JSON.parse and reads as\n // \"Unexpected end of JSON input\" in the admin panel, which tells an editor\n // nothing about what to change.\n if (choice?.finish_reason === 'length') {\n throw new Error(\n `OpenAI ran out of tokens before finishing the alt text (max_completion_tokens: ${maxTokens})`,\n )\n }\n\n const content = choice?.message?.content\n\n if (typeof content !== 'string') {\n throw new Error('No result from OpenAI')\n }\n\n try {\n return JSON.parse(content)\n } catch {\n throw new Error('OpenAI returned a response that was not valid JSON')\n }\n },\n instructions,\n key: 'openai',\n label: 'OpenAI',\n supportedMimeTypes,\n timeoutMs,\n })\n"],"names":["createVisionResolver","VisionProviderError","OPENAI_SUPPORTED_MIME_TYPES","openAIResolver","apiKey","baseUrl","instructions","model","supportedMimeTypes","timeoutMs","generate","filename","imageThumbnailUrl","resolvedInstructions","maxTokens","responseSchema","signal","response","fetch","body","JSON","stringify","max_completion_tokens","messages","content","role","type","image_url","url","text","response_format","json_schema","name","schema","strict","headers","Authorization","method","ok","catch","slice","label","status","completion","json","choice","choices","finish_reason","Error","message","parse","key"],"mappings":"AAGA,SAASA,oBAAoB,EAAEC,mBAAmB,QAAQ,4BAA2B;AAyCrF,+DAA+D,GAC/D,MAAMC,8BAA8B;IAAC;IAAc;IAAa;IAAa;CAAa;AAE1F;;;;;;;;;;;;;;;;;;;;;;;;;CAyBC,GACD,OAAO,MAAMC,iBAAiB,CAAC,EAC7BC,MAAM,EACNC,UAAU,2BAA2B,EACrCC,YAAY,EACZC,QAAQ,cAAc,EACtBC,qBAAqBN,2BAA2B,EAChDO,YAAY,MAAM,EACG,GACrBT,qBAAqB;QACnBI;QACAM,UAAU,OAAO,EACfC,QAAQ,EACRC,iBAAiB,EACjBN,cAAcO,oBAAoB,EAClCC,SAAS,EACTC,cAAc,EACdC,MAAM,EACP;YACC,MAAMC,WAAW,MAAMC,MAAM,GAAGb,QAAQ,iBAAiB,CAAC,EAAE;gBAC1Dc,MAAMC,KAAKC,SAAS,CAAC;oBACnBC,uBAAuBR;oBACvBS,UAAU;wBACR;4BAAEC,SAASX;4BAAsBY,MAAM;wBAAS;wBAChD;4BACED,SAAS;gCACP;oCAAEE,MAAM;oCAAaC,WAAW;wCAAEC,KAAKhB;oCAAkB;gCAAE;mCACvDD,WAAW;oCAAC;wCAAEe,MAAM;wCAAQG,MAAMlB;oCAAS;iCAAE,GAAG,EAAE;6BACvD;4BACDc,MAAM;wBACR;qBACD;oBACDlB;oBACAuB,iBAAiB;wBACfJ,MAAM;wBACNK,aAAa;4BAAEC,MAAM;4BAAQC,QAAQlB;4BAAgBmB,QAAQ;wBAAK;oBACpE;gBACF;gBACAC,SAAS;oBAAEC,eAAe,CAAC,OAAO,EAAEhC,QAAQ;oBAAE,gBAAgB;gBAAmB;gBACjFiC,QAAQ;gBACRrB;YACF;YAEA,IAAI,CAACC,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;oBAAUC,QAAQzB,SAASyB,MAAM;gBAAC;YACjF;YAEA,MAAMC,aAAc,MAAM1B,SAAS2B,IAAI;YAGvC,MAAMC,SAASF,WAAWG,OAAO,EAAE,CAAC,EAAE;YAEtC,wEAAwE;YACxE,2EAA2E;YAC3E,gCAAgC;YAChC,IAAID,QAAQE,kBAAkB,UAAU;gBACtC,MAAM,IAAIC,MACR,CAAC,+EAA+E,EAAElC,UAAU,CAAC,CAAC;YAElG;YAEA,MAAMU,UAAUqB,QAAQI,SAASzB;YAEjC,IAAI,OAAOA,YAAY,UAAU;gBAC/B,MAAM,IAAIwB,MAAM;YAClB;YAEA,IAAI;gBACF,OAAO5B,KAAK8B,KAAK,CAAC1B;YACpB,EAAE,OAAM;gBACN,MAAM,IAAIwB,MAAM;YAClB;QACF;QACA1C;QACA6C,KAAK;QACLV,OAAO;QACPjC;QACAC;IACF,GAAE"}
|