@directus/api 36.0.0-rc.1 → 36.0.1

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.
@@ -6,6 +6,7 @@ import "../../providers/index.js";
6
6
  import { getAITelemetryConfig } from "../../telemetry/index.js";
7
7
  import { SYSTEM_PROMPT } from "../constants/system-prompt.js";
8
8
  import { formatContextForSystemPrompt } from "../utils/format-context.js";
9
+ import { applyAnthropicConversationCaching, buildCacheAwareSystemPrompt, formatUsageWithCacheTokens, sortToolsByName } from "../utils/prompt-caching.js";
9
10
  import { transformFilePartsForProvider } from "./transform-file-parts.js";
10
11
  import { ServiceUnavailableError } from "@directus/errors";
11
12
  import { convertToModelMessages, stepCountIs, streamText, wrapLanguageModel } from "ai";
@@ -27,35 +28,25 @@ const createUiStream = async (messages, { provider, model, tools, aiSettings, sy
27
28
  model: languageModel,
28
29
  middleware: devToolsMiddleware
29
30
  });
30
- const fullSystemPrompt = contextBlock ? baseSystemPrompt + contextBlock : baseSystemPrompt;
31
- const finalTools = applyAnthropicToolSearch(provider, model, tools);
31
+ const streamSystemPrompt = buildCacheAwareSystemPrompt(provider, provider === "anthropic" || !contextBlock ? baseSystemPrompt : baseSystemPrompt + contextBlock);
32
+ const finalTools = sortToolsByName(applyAnthropicToolSearch(provider, model, tools));
32
33
  const telemetryConfig = getAITelemetryConfig({
33
34
  provider,
34
35
  model,
35
36
  userId,
36
37
  role
37
38
  });
39
+ const streamMessages = applyAnthropicConversationCaching(provider, await convertToModelMessages(transformFilePartsForProvider(messages)), contextBlock);
38
40
  return streamText({
39
- system: baseSystemPrompt,
41
+ system: streamSystemPrompt,
40
42
  model: languageModel,
41
- messages: await convertToModelMessages(transformFilePartsForProvider(messages)),
43
+ messages: streamMessages,
42
44
  stopWhen: [stepCountIs(10)],
43
45
  providerOptions,
44
46
  tools: finalTools,
45
47
  ...telemetryConfig ? { experimental_telemetry: telemetryConfig } : {},
46
- prepareStep: () => {
47
- if (contextBlock) return { system: fullSystemPrompt };
48
- return {};
49
- },
50
- onFinish({ usage }) {
51
- if (onUsage) {
52
- const { inputTokens, outputTokens, totalTokens } = usage;
53
- onUsage({
54
- inputTokens,
55
- outputTokens,
56
- totalTokens
57
- });
58
- }
48
+ onFinish(result) {
49
+ if (onUsage) onUsage(formatUsageWithCacheTokens(result));
59
50
  }
60
51
  });
61
52
  };
@@ -70,8 +70,6 @@ ${promptBlocks}
70
70
  </custom_instructions>`);
71
71
  }
72
72
  const sections = [];
73
- const now = /* @__PURE__ */ new Date();
74
- sections.push(`## Current Date\n${now.toISOString().split("T")[0]}`);
75
73
  if (context.page) {
76
74
  const page = context.page;
77
75
  const pageLines = [`Path: ${escapeAngleBrackets(String(page.path))}`];
@@ -89,6 +87,8 @@ Use the items tool to fetch additional fields or update items when asked.
89
87
 
90
88
  ${itemLines}`);
91
89
  }
90
+ const now = /* @__PURE__ */ new Date();
91
+ sections.push(`## Current Date\n${now.toISOString().split("T")[0]}`);
92
92
  if (sections.length > 0) parts.push(`<user_context>\n${sections.join("\n\n")}\n</user_context>`);
93
93
  if (groups.visualElements.length > 0) {
94
94
  const elementLines = groups.visualElements.map(formatVisualElement).join("\n\n");
@@ -0,0 +1,85 @@
1
+ //#region src/ai/chat/utils/prompt-caching.ts
2
+ function buildCacheAwareSystemPrompt(provider, content) {
3
+ if (provider !== "anthropic") return content;
4
+ return {
5
+ role: "system",
6
+ content,
7
+ providerOptions: { anthropic: { cacheControl: { type: "ephemeral" } } }
8
+ };
9
+ }
10
+ /**
11
+ * For Anthropic, place a cache breakpoint on the last existing message so the conversation
12
+ * prefix (tools + system + history) caches as it grows, and append the per-request page
13
+ * context as a new user message after the breakpoint so it stays out of the cached prefix.
14
+ *
15
+ * Context is only appended after a real user turn. On multi-step continuations the last
16
+ * message is an assistant or tool result; appending a user message there would make the
17
+ * model respond to the context instead of synthesizing the tool output. The model still
18
+ * sees context from the originating user turn earlier in the conversation.
19
+ *
20
+ * Other providers either auto-cache (Google/OpenAI) or don't support this, so we keep the
21
+ * context inside the system prompt for them (handled upstream).
22
+ */
23
+ function applyAnthropicConversationCaching(provider, messages, contextBlock) {
24
+ if (provider !== "anthropic" || messages.length === 0) return messages;
25
+ const lastIndex = messages.length - 1;
26
+ const messagesWithCache = messages.map((message, index) => index === lastIndex ? {
27
+ ...message,
28
+ providerOptions: {
29
+ ...message.providerOptions,
30
+ anthropic: {
31
+ ...message.providerOptions?.["anthropic"],
32
+ cacheControl: { type: "ephemeral" }
33
+ }
34
+ }
35
+ } : message);
36
+ if (!contextBlock || messages[lastIndex]?.role !== "user") return messagesWithCache;
37
+ return [...messagesWithCache, {
38
+ role: "user",
39
+ content: contextBlock
40
+ }];
41
+ }
42
+ function sortToolsByName(tools) {
43
+ return Object.fromEntries(Object.entries(tools).sort(([a], [b]) => {
44
+ if (a < b) return -1;
45
+ if (a > b) return 1;
46
+ return 0;
47
+ }));
48
+ }
49
+ function formatUsageWithCacheTokens(result) {
50
+ const usage = result.totalUsage ?? result.usage;
51
+ const providerMetadata = result.steps?.map((step) => step.providerMetadata) ?? [result.providerMetadata];
52
+ const { inputTokens, outputTokens, totalTokens } = usage;
53
+ return {
54
+ inputTokens,
55
+ outputTokens,
56
+ totalTokens,
57
+ ...getCacheTokenUsage(usage, providerMetadata)
58
+ };
59
+ }
60
+ function getCacheTokenUsage(usage, providerMetadata) {
61
+ const cacheReadTokens = usage.inputTokenDetails?.cacheReadTokens ?? usage.cachedInputTokens ?? sumNumbers([...providerMetadata.map((metadata) => getProviderMetadataNumber(metadata, "anthropic", "cacheReadInputTokens")), ...providerMetadata.map(getGoogleCachedContentTokenCount)]);
62
+ const cacheCreationTokens = usage.inputTokenDetails?.cacheWriteTokens ?? sumNumbers(providerMetadata.map((metadata) => getProviderMetadataNumber(metadata, "anthropic", "cacheCreationInputTokens")));
63
+ return {
64
+ ...cacheReadTokens !== void 0 ? { cacheReadTokens } : {},
65
+ ...cacheCreationTokens !== void 0 ? { cacheCreationTokens } : {}
66
+ };
67
+ }
68
+ function getProviderMetadataNumber(providerMetadata, providerName, fieldName) {
69
+ const value = providerMetadata?.[providerName]?.[fieldName];
70
+ return typeof value === "number" ? value : void 0;
71
+ }
72
+ function getGoogleCachedContentTokenCount(providerMetadata) {
73
+ const usageMetadata = providerMetadata?.["google"]?.["usageMetadata"];
74
+ if (!usageMetadata || typeof usageMetadata !== "object" || Array.isArray(usageMetadata)) return;
75
+ const cachedContentTokenCount = usageMetadata["cachedContentTokenCount"];
76
+ return typeof cachedContentTokenCount === "number" ? cachedContentTokenCount : void 0;
77
+ }
78
+ function sumNumbers(values) {
79
+ const definedValues = values.filter((value) => typeof value === "number");
80
+ if (definedValues.length === 0) return;
81
+ return definedValues.reduce((sum, value) => sum + value, 0);
82
+ }
83
+
84
+ //#endregion
85
+ export { applyAnthropicConversationCaching, buildCacheAwareSystemPrompt, formatUsageWithCacheTokens, sortToolsByName };
@@ -34,7 +34,8 @@ function extractError(error, data) {
34
34
  const matches = error.message.match(/"(.*?)"/g);
35
35
  if (!matches) return error;
36
36
  const collection = matches[0].slice(1, -1);
37
- const field = matches[1]?.slice(1, -1) ?? null;
37
+ const fields = Object.keys(data);
38
+ const field = fields.length === 1 ? fields[0] : null;
38
39
  return new ValueOutOfRangeError({
39
40
  collection,
40
41
  field,
@@ -3,7 +3,7 @@ import { ItemsService } from "../../../services/items.js";
3
3
  import { getSchema } from "../../../utils/get-schema.js";
4
4
  import "../../../services/index.js";
5
5
  import { isEqual } from "lodash-es";
6
- import { appAccessMinimalPermissions, appRecommendedPermissions } from "@directus/system-data";
6
+ import { appRecommendedPermissions } from "@directus/system-data";
7
7
 
8
8
  //#region src/license/entitlements/lib/custom-permission-rules-enabled.ts
9
9
  function hasCustomRule(permission) {
@@ -16,11 +16,6 @@ function isRecommendedAppPermission(permission) {
16
16
  if (!foundPermission) return false;
17
17
  return isEqual(foundPermission.fields ?? null, permission.fields ?? null) && isEqual(foundPermission.permissions ?? null, permission.permissions ?? null);
18
18
  }
19
- function isMinimumAppPermission(permission) {
20
- const foundPermission = appAccessMinimalPermissions.find((p) => p.action === permission.action && p.collection === permission.collection);
21
- if (!foundPermission) return false;
22
- return isEqual(foundPermission.fields ?? null, permission.fields ?? null) && isEqual(foundPermission.permissions ?? null, permission.permissions ?? null) && isEqual(foundPermission.validation ?? null, permission.validation ?? null) && isEqual(foundPermission.presets ?? null, permission.presets ?? null);
23
- }
24
19
  async function checkCustomPermissionRules(opts) {
25
20
  const knex = opts?.knex ?? database_default();
26
21
  return (await new ItemsService("directus_permissions", {
@@ -38,4 +33,4 @@ async function checkCustomPermissionRules(opts) {
38
33
  }
39
34
 
40
35
  //#endregion
41
- export { checkCustomPermissionRules, hasCustomRule, isMinimumAppPermission, isRecommendedAppPermission };
36
+ export { checkCustomPermissionRules, hasCustomRule, isRecommendedAppPermission };
@@ -8,10 +8,49 @@ import { toBoolean } from "@directus/utils";
8
8
  import { USER_INACTIVE_LICENSE_STATUS } from "@directus/constants";
9
9
 
10
10
  //#region src/license/entitlements/lib/seats.ts
11
+ /**
12
+ * Group access rows to the admin/app users and roles that occupy a seat.
13
+ */
14
+ function getSeatUsersAndRoles(accessRows) {
15
+ const adminRoles = /* @__PURE__ */ new Set();
16
+ const appRoles = /* @__PURE__ */ new Set();
17
+ const adminUsers = /* @__PURE__ */ new Set();
18
+ const appUsers = /* @__PURE__ */ new Set();
19
+ const appUsersByRole = /* @__PURE__ */ new Map();
20
+ for (const accessRow of accessRows) {
21
+ const { admin_access, app_access } = accessRow["policy"] || {};
22
+ const isAdmin = toBoolean(admin_access);
23
+ const isApp = !isAdmin && toBoolean(app_access);
24
+ if (!isAdmin && !isApp) continue;
25
+ if (accessRow["user"] && accessRow["user"].status === "active") {
26
+ const { id, role } = accessRow["user"];
27
+ if (isAdmin) {
28
+ adminUsers.add(id);
29
+ appUsers.delete(id);
30
+ } else if (adminUsers.has(id) === false && (!role || adminRoles.has(role) === false)) {
31
+ appUsers.add(id);
32
+ if (role) {
33
+ const roleUsers = appUsersByRole.get(role) ?? /* @__PURE__ */ new Set();
34
+ appUsersByRole.set(role, roleUsers.add(id));
35
+ }
36
+ }
37
+ }
38
+ if (accessRow["role"]) if (isAdmin) {
39
+ adminRoles.add(accessRow["role"]);
40
+ for (const id of appUsersByRole.get(accessRow["role"]) ?? []) appUsers.delete(id);
41
+ } else appRoles.add(accessRow["role"]);
42
+ }
43
+ return {
44
+ adminUsers,
45
+ appUsers,
46
+ adminRoles,
47
+ appRoles
48
+ };
49
+ }
11
50
  async function getActiveSeats(opts) {
12
51
  const knex = opts?.knex ?? database_default();
13
52
  const schema = await getSchema({ database: knex });
14
- const accessRows = await new AccessService({
53
+ const { adminUsers, appUsers, adminRoles, appRoles } = getSeatUsersAndRoles(await new AccessService({
15
54
  schema,
16
55
  knex
17
56
  }).readByQuery({
@@ -24,22 +63,7 @@ async function getActiveSeats(opts) {
24
63
  "policy.admin_access"
25
64
  ],
26
65
  limit: -1
27
- });
28
- const adminRoles = /* @__PURE__ */ new Set();
29
- const appRoles = /* @__PURE__ */ new Set();
30
- const adminUsers = /* @__PURE__ */ new Set();
31
- const appUsers = /* @__PURE__ */ new Set();
32
- for (const accessRow of accessRows) {
33
- const isAdmin = toBoolean(accessRow["policy"]?.["admin_access"]);
34
- const isApp = !isAdmin && toBoolean(accessRow["policy"]?.["app_access"]);
35
- if (!isAdmin && !isApp) continue;
36
- if (accessRow["user"] && accessRow["user"].status === "active") {
37
- if (isAdmin) adminUsers.add(accessRow["user"].id);
38
- else if (adminUsers.has(accessRow["user"].id) === false && adminRoles.has(accessRow["user"]?.role) === false) appUsers.add(accessRow["user"].id);
39
- }
40
- if (accessRow["role"]) if (isAdmin) adminRoles.add(accessRow["role"]);
41
- else appRoles.add(accessRow["role"]);
42
- }
66
+ }));
43
67
  const { adminRoles: allAdminRoles, appRoles: allAppRoles } = await fetchAccessRoles({
44
68
  adminRoles,
45
69
  appRoles
@@ -100,4 +124,4 @@ async function resolveSeats(seats, ctx) {
100
124
  }
101
125
 
102
126
  //#endregion
103
- export { countActiveSeats, getActiveSeats, resolveSeats };
127
+ export { countActiveSeats, getActiveSeats, getSeatUsersAndRoles, resolveSeats };
@@ -3,6 +3,7 @@ import database_default from "../../../database/index.js";
3
3
  import { getSchema } from "../../../utils/get-schema.js";
4
4
  import { UsersService } from "../../../services/users.js";
5
5
  import "../../../services/index.js";
6
+ import { isObject } from "@directus/utils";
6
7
  import { USER_INACTIVE_LICENSE_STATUS } from "@directus/constants";
7
8
 
8
9
  //#region src/license/entitlements/lib/sso-enabled.ts
@@ -33,7 +34,7 @@ async function resolveSSOUsers(resolution, ctx) {
33
34
  _neq: DEFAULT_AUTH_PROVIDER,
34
35
  _nnull: true
35
36
  } }, { id: { _neq: adminId } }] } }, { status: USER_INACTIVE_LICENSE_STATUS });
36
- if (typeof resolution === "object" && Object.keys(resolution.admin).length) {
37
+ if (isObject(resolution) && Object.keys(resolution.admin ?? {}).length) {
37
38
  const payload = { provider: DEFAULT_AUTH_PROVIDER };
38
39
  if (resolution.admin.email?.length) payload["email"] = resolution.admin.email;
39
40
  if (resolution.admin.password?.length) payload["password"] = resolution.admin.password;
@@ -507,17 +507,22 @@ var ItemsService = class ItemsService {
507
507
  });
508
508
  const snapshotFields = difference(fields, getRelationsForCollection(this.schema, this.collection));
509
509
  const snapshots = await itemsService.readMany(keys, { fields: snapshotFields.length > 0 ? snapshotFields : ["*"] });
510
+ const snapshotsByKey = new Map(snapshots.map((snapshot) => [String(snapshot[primaryKeyField]), snapshot]));
510
511
  const revisionsService = new RevisionsService({
511
512
  knex: trx,
512
513
  schema: this.schema
513
514
  });
514
- const revisions = (await Promise.all(activity.map(async (activity$1, index) => ({
515
- activity: activity$1,
516
- collection: this.collection,
517
- item: keys[index],
518
- data: Array.isArray(snapshots) && snapshots[index] ? await payloadService.prepareDelta(snapshots[index]) : null,
519
- delta: await payloadService.prepareDelta(payloadWithTypeCasting)
520
- })))).filter((revision) => revision.delta);
515
+ const revisions = (await Promise.all(activity.map(async (activity$1, index) => {
516
+ const key = keys[index];
517
+ const snapshot = snapshotsByKey.get(String(key));
518
+ return {
519
+ activity: activity$1,
520
+ collection: this.collection,
521
+ item: key,
522
+ data: snapshot ? await payloadService.prepareDelta(snapshot) : null,
523
+ delta: await payloadService.prepareDelta(payloadWithTypeCasting)
524
+ };
525
+ }))).filter((revision) => revision.delta);
521
526
  const revisionIDs = await revisionsService.createMany(revisions);
522
527
  for (let i = 0; i < revisionIDs.length; i++) {
523
528
  const revisionID = revisionIDs[i];
@@ -41,13 +41,13 @@ var PermissionsService = class extends ItemsService {
41
41
  return withAppMinimalPermissions(this.accountability, mappedPermissions, query.filter);
42
42
  }
43
43
  async createOne(data, opts) {
44
- if (hasCustomRule(data) && !isRecommendedAppPermission(data)) await getEntitlementManager().assert("custom_permission_rules_enabled", { knex: this.knex });
44
+ if (!getEntitlementManager().isEntitled("custom_permission_rules_enabled") && hasCustomRule(data) && !isRecommendedAppPermission(data)) throw new ResourceRestrictedError({ category: "custom_permission_rules_enabled" });
45
45
  const res = await super.createOne(data, opts);
46
46
  await this.clearCaches(opts);
47
47
  return res;
48
48
  }
49
49
  async updateMany(keys, data, opts) {
50
- if (hasCustomRule(data) && !isRecommendedAppPermission(data)) await getEntitlementManager().assert("custom_permission_rules_enabled", { knex: this.knex });
50
+ if (!getEntitlementManager().isEntitled("custom_permission_rules_enabled") && hasCustomRule(data) && !isRecommendedAppPermission(data)) throw new ResourceRestrictedError({ category: "custom_permission_rules_enabled" });
51
51
  const res = await super.updateMany(keys, data, opts);
52
52
  await this.clearCaches(opts);
53
53
  return res;
@@ -34,6 +34,7 @@ async function getCacheKey(req) {
34
34
  path,
35
35
  query: isGraphQl ? getGraphqlQueryAndVariables(req) : req.sanitizedQuery,
36
36
  ...flowTriggerQuery && { rawQuery: flowTriggerQuery },
37
+ ...req.accountability?.share && { share: req.accountability.share },
37
38
  ...includeIp && { ip: req.accountability.ip }
38
39
  });
39
40
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@directus/api",
3
- "version": "36.0.0-rc.1",
3
+ "version": "36.0.1",
4
4
  "description": "Directus is a real-time API and App dashboard for managing SQL database content",
5
5
  "keywords": [
6
6
  "directus",
@@ -166,30 +166,30 @@
166
166
  "ws": "8.18.3",
167
167
  "zod": "4.1.12",
168
168
  "zod-validation-error": "4.0.2",
169
- "@directus/app": "16.0.0-rc.1",
170
- "@directus/constants": "14.4.0-rc.1",
171
- "@directus/errors": "2.4.0-rc.0",
172
- "@directus/ai": "1.3.2-rc.0",
173
- "@directus/env": "6.0.0-rc.1",
174
- "@directus/extensions": "4.0.0-rc.1",
175
- "@directus/format-title": "13.0.0-rc.0",
176
- "@directus/extensions-registry": "4.0.0-rc.1",
177
- "@directus/memory": "4.0.0-rc.1",
178
- "@directus/pressure": "4.0.0-rc.1",
179
- "@directus/extensions-sdk": "18.0.0-rc.1",
180
- "@directus/schema": "14.0.0-rc.0",
181
- "@directus/storage": "13.0.0-rc.0",
182
- "@directus/specs": "14.0.0-rc.0",
183
- "@directus/storage-driver-azure": "13.0.0-rc.1",
184
- "@directus/storage-driver-cloudinary": "13.0.0-rc.1",
185
- "@directus/storage-driver-gcs": "13.0.0-rc.1",
186
- "@directus/storage-driver-local": "13.0.0-rc.0",
187
- "@directus/storage-driver-s3": "13.0.0-rc.1",
188
- "@directus/system-data": "4.5.0-rc.1",
189
- "@directus/utils": "13.5.0-rc.1",
190
- "@directus/storage-driver-supabase": "4.0.0-rc.1",
191
- "@directus/validation": "3.0.0-rc.1",
192
- "directus": "12.0.0-rc.2"
169
+ "@directus/ai": "1.3.2",
170
+ "@directus/app": "16.1.0",
171
+ "@directus/constants": "14.4.0",
172
+ "@directus/env": "6.0.0",
173
+ "@directus/errors": "2.4.0",
174
+ "@directus/extensions": "4.0.0",
175
+ "@directus/format-title": "13.0.0",
176
+ "@directus/memory": "4.0.0",
177
+ "@directus/extensions-sdk": "18.0.0",
178
+ "@directus/extensions-registry": "4.0.0",
179
+ "@directus/schema": "14.0.0",
180
+ "@directus/specs": "14.0.0",
181
+ "@directus/pressure": "4.0.0",
182
+ "@directus/storage-driver-azure": "13.0.0",
183
+ "@directus/storage-driver-cloudinary": "13.0.0",
184
+ "@directus/storage-driver-local": "13.0.0",
185
+ "@directus/storage-driver-gcs": "13.0.0",
186
+ "@directus/storage-driver-supabase": "4.0.0",
187
+ "@directus/storage-driver-s3": "13.0.0",
188
+ "@directus/system-data": "4.5.0",
189
+ "@directus/storage": "13.0.0",
190
+ "@directus/utils": "13.5.0",
191
+ "@directus/validation": "3.0.0",
192
+ "directus": "12.0.1"
193
193
  },
194
194
  "devDependencies": {
195
195
  "@directus/tsconfig": "4.0.0",
@@ -224,15 +224,15 @@
224
224
  "@types/stream-json": "1.7.8",
225
225
  "@types/wellknown": "0.5.8",
226
226
  "@types/ws": "8.18.1",
227
- "@vitest/coverage-v8": "3.2.4",
227
+ "@vitest/coverage-v8": "3.2.6",
228
228
  "copyfiles": "2.4.1",
229
229
  "form-data": "4.0.4",
230
230
  "get-port": "7.1.0",
231
231
  "knex-mock-client": "3.0.2",
232
232
  "typescript": "5.9.3",
233
- "vitest": "3.2.4",
234
- "@directus/schema-builder": "1.0.0-rc.1",
235
- "@directus/types": "16.0.0-rc.1"
233
+ "vitest": "3.2.6",
234
+ "@directus/schema-builder": "1.0.0",
235
+ "@directus/types": "16.0.0"
236
236
  },
237
237
  "optionalDependencies": {
238
238
  "@keyv/redis": "3.0.1",