@mvriu5/payload-ai 1.5.0 → 1.6.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 CHANGED
@@ -101,6 +101,8 @@ const options: PayloadAIPluginOptions = {
101
101
  },
102
102
  ],
103
103
  maxOutputTokens: 1200,
104
+ promptCaching: true,
105
+ translate: true,
104
106
  maxTokenUsage: {
105
107
  type: "user",
106
108
  perDay: 50_000,
@@ -135,6 +137,26 @@ payloadAiPlugin({
135
137
 
136
138
  Generate controls are added to `text`, `textarea`, `richText`, and `json` fields in collection and global edit views. When disabled, the plugin also omits the `/ai-generate-field` endpoint. The embedded AI Assistant and dashboard remain available.
137
139
 
140
+ ### `translate`
141
+
142
+ Controls document translation for localized collections and globals. It defaults to `true`.
143
+
144
+ ```ts
145
+ payloadAiPlugin({
146
+ translate: false,
147
+ })
148
+ ```
149
+
150
+ When enabled, the plugin adds a `Translate` action to the document header. It is shown only when:
151
+
152
+ - a non-default locale is selected
153
+ - at least one localized field is empty in that locale
154
+ - the corresponding field in the default locale contains source content
155
+
156
+ Translation fills only empty localized fields in the current Payload form and marks it as modified. Existing translated values are never overwritten. It does not save the document or global, so the generated values can be reviewed and validated before saving.
157
+
158
+ When disabled, the plugin does not register the header control or the `/ai-translate-document` endpoint.
159
+
138
160
  ### `collections`
139
161
 
140
162
  Restricts AI read and write proposals to enabled collection slugs. If omitted, all non-internal Payload collections are available.
@@ -276,6 +298,18 @@ Use `type: "user"` to enforce separate budgets per authenticated user, or `type:
276
298
 
277
299
  Completed model usage is stored in the hidden `payload-ai-usage` collection. Requests made after a limit is reached return HTTP `429`. Because providers report token usage after completion, the request that crosses a limit is allowed to finish and subsequent requests are blocked.
278
300
 
301
+ ### `promptCaching`
302
+
303
+ Enables provider prompt-caching hints and defaults to `true`. The chat endpoint keeps static instructions and Payload schemas in a stable prompt prefix, adds Anthropic cache-control breakpoints, and supplies a stable OpenAI prompt cache key. Gemini uses the same stable prefix with its implicit caching.
304
+
305
+ ```ts
306
+ payloadAiPlugin({
307
+ promptCaching: false,
308
+ })
309
+ ```
310
+
311
+ Disable it when explicit provider-side caching is not desired. Dynamic document data and user prompts are never placed in the cacheable schema section. Managed providers with a custom `baseURL` receive the stable prefix but no provider-specific cache parameters, preserving compatibility with Ollama, vLLM, and other OpenAI-compatible endpoints.
312
+
279
313
  ### `allowUserApiKeys`
280
314
 
281
315
  Controls whether the plugin adds an `aiApiKey` field to the admin user collection.
@@ -0,0 +1,23 @@
1
+ import type { SystemModelMessage } from "ai";
2
+ import type { AIProvider } from "./providerOptions.js";
3
+ type PromptCacheContext = {
4
+ cacheable: Record<string, unknown>[];
5
+ dynamic: Record<string, unknown>[];
6
+ };
7
+ type PromptCacheProviderOptions = NonNullable<SystemModelMessage["providerOptions"]>;
8
+ export declare const splitPromptCacheContext: (mentionContext: Record<string, unknown>[]) => PromptCacheContext;
9
+ export declare const createCachedSystemMessages: ({ cacheableContext, cacheableInstructions, dynamicInstructions, enabled, provider, staticInstructions, }: {
10
+ cacheableContext: Record<string, unknown>[];
11
+ cacheableInstructions: string[];
12
+ dynamicInstructions: string[];
13
+ enabled: boolean;
14
+ provider: AIProvider;
15
+ staticInstructions: string[];
16
+ }) => SystemModelMessage[];
17
+ export declare const createPromptCacheProviderOptions: ({ cacheKeyParts, enabled, model, provider, }: {
18
+ cacheKeyParts: unknown[];
19
+ enabled: boolean;
20
+ model: string;
21
+ provider: AIProvider;
22
+ }) => PromptCacheProviderOptions | undefined;
23
+ export {};
@@ -0,0 +1,120 @@
1
+ import { createHash } from "node:crypto";
2
+ const getContextKey = (context)=>{
3
+ return [
4
+ context.type,
5
+ context.parent,
6
+ context.collection,
7
+ context.slug
8
+ ].filter(Boolean).join(":") || JSON.stringify(context);
9
+ };
10
+ export const splitPromptCacheContext = (mentionContext)=>{
11
+ const cacheable = [];
12
+ const dynamic = [];
13
+ const cacheableKeys = new Set();
14
+ const addCacheable = (context)=>{
15
+ const key = getContextKey(context);
16
+ if (cacheableKeys.has(key)) return;
17
+ cacheableKeys.add(key);
18
+ cacheable.push(context);
19
+ };
20
+ for (const context of mentionContext){
21
+ if ([
22
+ "block",
23
+ "collection"
24
+ ].includes(String(context.type))) {
25
+ addCacheable(context);
26
+ continue;
27
+ }
28
+ if (context.type === "global" && "doc" in context) {
29
+ const { doc, ...schema } = context;
30
+ addCacheable(schema);
31
+ dynamic.push({
32
+ doc,
33
+ slug: context.slug,
34
+ type: "globalDocument"
35
+ });
36
+ continue;
37
+ }
38
+ if (context.type === "global") {
39
+ addCacheable(context);
40
+ continue;
41
+ }
42
+ if (context.type === "mediaAttachment" && context.schema && typeof context.schema === "object") {
43
+ addCacheable(context.schema);
44
+ const { schema: _schema, ...attachmentContext } = context;
45
+ dynamic.push(attachmentContext);
46
+ continue;
47
+ }
48
+ dynamic.push(context);
49
+ }
50
+ return {
51
+ cacheable,
52
+ dynamic
53
+ };
54
+ };
55
+ const withAnthropicCacheControl = (message, enabled, provider)=>{
56
+ if (!enabled || provider !== "claude") return message;
57
+ return {
58
+ ...message,
59
+ providerOptions: {
60
+ anthropic: {
61
+ cacheControl: {
62
+ type: "ephemeral"
63
+ }
64
+ }
65
+ }
66
+ };
67
+ };
68
+ export const createCachedSystemMessages = ({ cacheableContext, cacheableInstructions, dynamicInstructions, enabled, provider, staticInstructions })=>{
69
+ const messages = [
70
+ withAnthropicCacheControl({
71
+ content: staticInstructions.join("\n"),
72
+ role: "system"
73
+ }, enabled, provider)
74
+ ];
75
+ const scopedContent = [
76
+ ...cacheableContext.length ? [
77
+ "Payload schema references JSON. Use exact field names and values:",
78
+ JSON.stringify(cacheableContext)
79
+ ] : [],
80
+ ...cacheableInstructions
81
+ ];
82
+ if (scopedContent.length > 0) {
83
+ messages.push(withAnthropicCacheControl({
84
+ content: scopedContent.join("\n"),
85
+ role: "system"
86
+ }, enabled, provider));
87
+ }
88
+ if (dynamicInstructions.length > 0) {
89
+ messages.push({
90
+ content: dynamicInstructions.join("\n"),
91
+ role: "system"
92
+ });
93
+ }
94
+ return messages;
95
+ };
96
+ export const createPromptCacheProviderOptions = ({ cacheKeyParts, enabled, model, provider })=>{
97
+ if (!enabled) return undefined;
98
+ if (provider === "openai") {
99
+ const digest = createHash("sha256").update(JSON.stringify([
100
+ "payload-ai-v1",
101
+ model,
102
+ ...cacheKeyParts
103
+ ])).digest("hex").slice(0, 32);
104
+ return {
105
+ openai: {
106
+ promptCacheKey: `payload-ai-${digest}`
107
+ }
108
+ };
109
+ }
110
+ if (provider === "openrouter" && model.toLowerCase().startsWith("anthropic/")) {
111
+ return {
112
+ openrouter: {
113
+ cache_control: {
114
+ type: "ephemeral"
115
+ }
116
+ }
117
+ };
118
+ }
119
+ return undefined;
120
+ };
@@ -0,0 +1,30 @@
1
+ import type { ProposalValidationIssue } from "../payload/proposalData.js";
2
+ export declare const maxProposalRepairAttempts = 1;
3
+ export type CompactProposalRepairIssue = {
4
+ code: string;
5
+ hint: string;
6
+ path: string;
7
+ };
8
+ type ProposalRepairTarget = {
9
+ collection?: string;
10
+ id?: string;
11
+ slug?: string;
12
+ tool: string;
13
+ };
14
+ export declare const compactProposalRepairIssues: (issues: ProposalValidationIssue[]) => CompactProposalRepairIssue[];
15
+ export declare const createProposalRepairKey: ({ collection, id, slug, tool }: ProposalRepairTarget) => string;
16
+ export declare const createProposalRepairTracker: () => {
17
+ beginCall(target: ProposalRepairTarget): "initial" | "blocked" | "repair";
18
+ registerFailure(target: ProposalRepairTarget): {
19
+ attempt: number;
20
+ errorCode: "REPAIR_EXHAUSTED";
21
+ maxAttempts: number;
22
+ retryable: boolean;
23
+ } | {
24
+ attempt: number;
25
+ errorCode: "INVALID_PROPOSAL_DATA";
26
+ maxAttempts: number;
27
+ retryable: boolean;
28
+ };
29
+ };
30
+ export {};
@@ -0,0 +1,76 @@
1
+ export const maxProposalRepairAttempts = 1;
2
+ const getIssueHint = (issue)=>{
3
+ switch(issue.code){
4
+ case "invalid_array":
5
+ return "Use an array of complete objects matching the child fields.";
6
+ case "invalid_block_type":
7
+ return "Use an exact blockType from the schema.";
8
+ case "invalid_blocks":
9
+ return "Use an array of block objects with blockType and exact field names.";
10
+ case "invalid_checkbox":
11
+ return "Use true or false.";
12
+ case "invalid_container":
13
+ return "Use an object matching the nested field schema.";
14
+ case "invalid_date":
15
+ return "Use a valid date string.";
16
+ case "invalid_option":
17
+ return issue.message;
18
+ case "invalid_relationship":
19
+ return "Use an existing document ID or { relationTo, value } for polymorphic relationships.";
20
+ case "missing_required_field":
21
+ return "Add the required field with a schema-compatible value.";
22
+ case "non_localized_field_in_secondary_locale":
23
+ return "Move this field to the primary locale data.";
24
+ case "unknown_field":
25
+ return "Remove it and use exact schema field names only.";
26
+ default:
27
+ return issue.message;
28
+ }
29
+ };
30
+ export const compactProposalRepairIssues = (issues)=>{
31
+ return issues.slice(0, 6).map((issue)=>({
32
+ code: issue.code,
33
+ hint: getIssueHint(issue).slice(0, 180),
34
+ path: issue.path
35
+ }));
36
+ };
37
+ export const createProposalRepairKey = ({ collection, id, slug, tool })=>{
38
+ return [
39
+ tool,
40
+ collection || slug || "",
41
+ id || ""
42
+ ].join(":");
43
+ };
44
+ export const createProposalRepairTracker = ()=>{
45
+ const statesByTarget = new Map();
46
+ return {
47
+ beginCall (target) {
48
+ const state = statesByTarget.get(createProposalRepairKey(target));
49
+ if (!state) return "initial";
50
+ if (state.repairCallConsumed) return "blocked";
51
+ state.repairCallConsumed = true;
52
+ return "repair";
53
+ },
54
+ registerFailure (target) {
55
+ const key = createProposalRepairKey(target);
56
+ const state = statesByTarget.get(key);
57
+ if (state) {
58
+ return {
59
+ attempt: maxProposalRepairAttempts,
60
+ errorCode: "REPAIR_EXHAUSTED",
61
+ maxAttempts: maxProposalRepairAttempts,
62
+ retryable: false
63
+ };
64
+ }
65
+ statesByTarget.set(key, {
66
+ repairCallConsumed: false
67
+ });
68
+ return {
69
+ attempt: 1,
70
+ errorCode: "INVALID_PROPOSAL_DATA",
71
+ maxAttempts: maxProposalRepairAttempts,
72
+ retryable: true
73
+ };
74
+ }
75
+ };
76
+ };
@@ -1,2 +1,3 @@
1
+ export declare const isSensitiveKey: (key: string) => boolean;
1
2
  export declare const containsSensitiveData: (value: unknown) => boolean;
2
3
  export declare const redactSensitiveData: (value: unknown) => unknown;
@@ -5,10 +5,12 @@ const sensitiveKeyPatterns = [
5
5
  /^aiApiKey$/i,
6
6
  /^authorization$/i,
7
7
  /^accessToken$/i,
8
+ /^password$/i,
9
+ /^privateKey$/i,
8
10
  /^refreshToken$/i,
9
11
  /^secret$/i
10
12
  ];
11
- const isSensitiveKey = (key)=>sensitiveKeyPatterns.some((pattern)=>pattern.test(key));
13
+ export const isSensitiveKey = (key)=>sensitiveKeyPatterns.some((pattern)=>pattern.test(key));
12
14
  export const containsSensitiveData = (value)=>{
13
15
  if (Array.isArray(value)) return value.some(containsSensitiveData);
14
16
  if (!isRecord(value)) return false;
@@ -0,0 +1,2 @@
1
+ declare const TranslateDocumentButton: () => import("react").JSX.Element | null;
2
+ export default TranslateDocumentButton;
@@ -0,0 +1,111 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Button, useConfig, useDocumentForm, useDocumentInfo, useLocale } from "@payloadcms/ui";
4
+ import { formatAdminURL } from "payload/shared";
5
+ import { useEffect, useState } from "react";
6
+ import styles from "./TranslateDocumentButton.module.css";
7
+ const TranslateDocumentButton = ()=>{
8
+ const configContext = useConfig();
9
+ const documentForm = useDocumentForm();
10
+ const documentInfo = useDocumentInfo();
11
+ const locale = useLocale();
12
+ const [available, setAvailable] = useState(false);
13
+ const [error, setError] = useState("");
14
+ const [isChecking, setIsChecking] = useState(true);
15
+ const [isTranslating, setIsTranslating] = useState(false);
16
+ const scope = documentInfo.collectionSlug ? {
17
+ slug: documentInfo.collectionSlug,
18
+ type: "collection"
19
+ } : documentInfo.globalSlug ? {
20
+ slug: documentInfo.globalSlug,
21
+ type: "global"
22
+ } : null;
23
+ const apiRoute = configContext?.config?.routes?.api;
24
+ const request = async (action)=>{
25
+ if (!apiRoute || !scope) return null;
26
+ const response = await fetch(formatAdminURL({
27
+ apiRoute,
28
+ path: "/ai-translate-document"
29
+ }), {
30
+ body: JSON.stringify({
31
+ action,
32
+ id: documentInfo.id,
33
+ locale: locale.code,
34
+ scope
35
+ }),
36
+ headers: {
37
+ "Content-Type": "application/json"
38
+ },
39
+ method: "POST"
40
+ });
41
+ const result = await response.json().catch(()=>null);
42
+ if (!response.ok || !result) throw new Error(result?.error || "Could not translate this locale.");
43
+ return result;
44
+ };
45
+ useEffect(()=>{
46
+ let active = true;
47
+ setAvailable(false);
48
+ setError("");
49
+ setIsChecking(true);
50
+ void request("status").then((result)=>{
51
+ if (active) setAvailable(Boolean(result?.available));
52
+ }).catch(()=>{
53
+ if (active) setAvailable(false);
54
+ }).finally(()=>{
55
+ if (active) setIsChecking(false);
56
+ });
57
+ return ()=>{
58
+ active = false;
59
+ };
60
+ }, [
61
+ apiRoute,
62
+ documentInfo.id,
63
+ locale.code,
64
+ scope?.slug,
65
+ scope?.type
66
+ ]);
67
+ const translate = async ()=>{
68
+ if (isTranslating) return;
69
+ setError("");
70
+ setIsTranslating(true);
71
+ try {
72
+ const result = await request("translate");
73
+ for (const entry of result?.values || []){
74
+ documentForm.dispatchFields({
75
+ ...entry.fieldType === "richText" ? {
76
+ initialValue: entry.value
77
+ } : {},
78
+ path: entry.path,
79
+ type: "UPDATE",
80
+ value: entry.value
81
+ });
82
+ }
83
+ documentForm.setModified(true);
84
+ setAvailable(false);
85
+ } catch (translationError) {
86
+ setError(translationError instanceof Error ? translationError.message : "Could not translate this locale.");
87
+ } finally{
88
+ setIsTranslating(false);
89
+ }
90
+ };
91
+ if (isChecking || !available) return null;
92
+ return /*#__PURE__*/ _jsxs("div", {
93
+ className: styles.wrapper,
94
+ children: [
95
+ error && /*#__PURE__*/ _jsx("span", {
96
+ className: styles.error,
97
+ role: "alert",
98
+ children: error
99
+ }),
100
+ /*#__PURE__*/ _jsx(Button, {
101
+ buttonStyle: "subtle",
102
+ disabled: isTranslating,
103
+ margin: false,
104
+ onClick: ()=>void translate(),
105
+ size: "medium",
106
+ children: isTranslating ? "Translating..." : "Translate"
107
+ })
108
+ ]
109
+ });
110
+ };
111
+ export default TranslateDocumentButton;
@@ -0,0 +1,11 @@
1
+ .wrapper {
2
+ align-items: center;
3
+ display: flex;
4
+ gap: 8px;
5
+ }
6
+
7
+ .error {
8
+ color: var(--theme-error-500);
9
+ font-size: 12px;
10
+ max-width: 240px;
11
+ }
@@ -2,7 +2,9 @@ import Dashboard from "../components/dashboard/Dashboard.js";
2
2
  import APIKeyField from "../components/APIKeyField.js";
3
3
  import AIInput from "../components/ai-input/AIInput.js";
4
4
  import GenerateField from "../components/generate-field/GenerateField.js";
5
+ import TranslateDocumentButton from "../components/translate-document/TranslateDocumentButton.js";
5
6
  export { Dashboard };
6
7
  export { APIKeyField as AIApiKeyField };
7
8
  export { AIInput };
8
9
  export { GenerateField };
10
+ export { TranslateDocumentButton };
@@ -2,7 +2,9 @@ import Dashboard from "../components/dashboard/Dashboard.js";
2
2
  import APIKeyField from "../components/APIKeyField.js";
3
3
  import AIInput from "../components/ai-input/AIInput.js";
4
4
  import GenerateField from "../components/generate-field/GenerateField.js";
5
+ import TranslateDocumentButton from "../components/translate-document/TranslateDocumentButton.js";
5
6
  export { Dashboard };
6
7
  export { APIKeyField as AIApiKeyField };
7
8
  export { AIInput };
8
9
  export { GenerateField };
10
+ export { TranslateDocumentButton };
@@ -39,6 +39,7 @@ type ChatOptions = {
39
39
  maxOutputTokens?: number;
40
40
  maxTokenUsage?: ResolvedMaxTokenUsageOptions;
41
41
  models?: AIModelConfig;
42
+ promptCaching?: boolean;
42
43
  providers?: ResolvedAIProviderConfig[];
43
44
  };
44
45
  export declare const createChatHandler: (options?: ChatOptions) => PayloadHandler;