@directus/api 36.0.0-rc.1 → 36.0.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.
@@ -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;
@@ -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.0",
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/app": "16.0.0",
170
+ "@directus/ai": "1.3.2",
171
+ "@directus/env": "6.0.0",
172
+ "@directus/constants": "14.4.0",
173
+ "@directus/errors": "2.4.0",
174
+ "@directus/extensions-registry": "4.0.0",
175
+ "@directus/extensions": "4.0.0",
176
+ "@directus/format-title": "13.0.0",
177
+ "@directus/memory": "4.0.0",
178
+ "@directus/extensions-sdk": "18.0.0",
179
+ "@directus/schema": "14.0.0",
180
+ "@directus/pressure": "4.0.0",
181
+ "@directus/storage": "13.0.0",
182
+ "@directus/storage-driver-azure": "13.0.0",
183
+ "@directus/storage-driver-local": "13.0.0",
184
+ "@directus/storage-driver-gcs": "13.0.0",
185
+ "@directus/storage-driver-cloudinary": "13.0.0",
186
+ "@directus/specs": "14.0.0",
187
+ "@directus/storage-driver-s3": "13.0.0",
188
+ "@directus/system-data": "4.5.0",
189
+ "@directus/storage-driver-supabase": "4.0.0",
190
+ "@directus/utils": "13.5.0",
191
+ "@directus/validation": "3.0.0",
192
+ "directus": "12.0.0"
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",