@directus/api 33.3.1 → 34.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.
- package/dist/ai/chat/lib/create-ui-stream.js +2 -1
- package/dist/ai/chat/lib/transform-file-parts.d.ts +12 -0
- package/dist/ai/chat/lib/transform-file-parts.js +36 -0
- package/dist/ai/files/adapters/anthropic.d.ts +3 -0
- package/dist/ai/files/adapters/anthropic.js +25 -0
- package/dist/ai/files/adapters/google.d.ts +3 -0
- package/dist/ai/files/adapters/google.js +58 -0
- package/dist/ai/files/adapters/index.d.ts +3 -0
- package/dist/ai/files/adapters/index.js +3 -0
- package/dist/ai/files/adapters/openai.d.ts +3 -0
- package/dist/ai/files/adapters/openai.js +22 -0
- package/dist/ai/files/controllers/upload.d.ts +2 -0
- package/dist/ai/files/controllers/upload.js +101 -0
- package/dist/ai/files/lib/fetch-provider.d.ts +1 -0
- package/dist/ai/files/lib/fetch-provider.js +23 -0
- package/dist/ai/files/lib/upload-to-provider.d.ts +4 -0
- package/dist/ai/files/lib/upload-to-provider.js +26 -0
- package/dist/ai/files/router.d.ts +1 -0
- package/dist/ai/files/router.js +5 -0
- package/dist/ai/files/types.d.ts +5 -0
- package/dist/ai/files/types.js +1 -0
- package/dist/ai/providers/anthropic-file-support.d.ts +12 -0
- package/dist/ai/providers/anthropic-file-support.js +94 -0
- package/dist/ai/providers/registry.js +3 -6
- package/dist/ai/tools/fields/index.d.ts +3 -3
- package/dist/ai/tools/fields/index.js +9 -3
- package/dist/ai/tools/flows/index.d.ts +16 -16
- package/dist/ai/tools/schema.d.ts +8 -8
- package/dist/ai/tools/schema.js +2 -2
- package/dist/app.js +10 -1
- package/dist/auth/drivers/oauth2.js +10 -4
- package/dist/auth/drivers/openid.js +10 -4
- package/dist/auth/drivers/saml.js +20 -10
- package/dist/auth/utils/resolve-login-redirect.d.ts +11 -0
- package/dist/auth/utils/resolve-login-redirect.js +62 -0
- package/dist/controllers/deployment-webhooks.d.ts +2 -0
- package/dist/controllers/deployment-webhooks.js +95 -0
- package/dist/controllers/deployment.js +61 -165
- package/dist/controllers/files.js +2 -1
- package/dist/controllers/server.js +32 -26
- package/dist/controllers/tus.js +33 -2
- package/dist/controllers/utils.js +18 -0
- package/dist/database/get-ast-from-query/lib/parse-fields.js +52 -26
- package/dist/database/helpers/date/dialects/oracle.js +2 -0
- package/dist/database/helpers/date/dialects/sqlite.js +2 -0
- package/dist/database/helpers/date/types.d.ts +1 -1
- package/dist/database/helpers/date/types.js +3 -1
- package/dist/database/helpers/fn/dialects/mssql.d.ts +1 -0
- package/dist/database/helpers/fn/dialects/mssql.js +21 -0
- package/dist/database/helpers/fn/dialects/mysql.d.ts +2 -0
- package/dist/database/helpers/fn/dialects/mysql.js +30 -0
- package/dist/database/helpers/fn/dialects/oracle.d.ts +1 -0
- package/dist/database/helpers/fn/dialects/oracle.js +21 -0
- package/dist/database/helpers/fn/dialects/postgres.d.ts +14 -0
- package/dist/database/helpers/fn/dialects/postgres.js +40 -0
- package/dist/database/helpers/fn/dialects/sqlite.d.ts +1 -0
- package/dist/database/helpers/fn/dialects/sqlite.js +12 -0
- package/dist/database/helpers/fn/json/parse-function.d.ts +19 -0
- package/dist/database/helpers/fn/json/parse-function.js +66 -0
- package/dist/database/helpers/fn/types.d.ts +8 -0
- package/dist/database/helpers/fn/types.js +19 -0
- package/dist/database/helpers/schema/dialects/mysql.d.ts +1 -0
- package/dist/database/helpers/schema/dialects/mysql.js +11 -0
- package/dist/database/helpers/schema/types.d.ts +1 -0
- package/dist/database/helpers/schema/types.js +3 -0
- package/dist/database/index.js +2 -1
- package/dist/database/migrations/20260211A-add-deployment-webhooks.d.ts +3 -0
- package/dist/database/migrations/20260211A-add-deployment-webhooks.js +37 -0
- package/dist/database/run-ast/lib/apply-query/aggregate.js +4 -4
- package/dist/database/run-ast/lib/apply-query/filter/get-filter-type.d.ts +2 -2
- package/dist/database/run-ast/lib/apply-query/filter/operator.js +17 -7
- package/dist/database/run-ast/lib/parse-current-level.js +8 -1
- package/dist/database/run-ast/run-ast.js +11 -1
- package/dist/database/run-ast/utils/apply-function-to-column-name.js +7 -1
- package/dist/database/run-ast/utils/get-column.js +13 -2
- package/dist/deployment/deployment.d.ts +25 -2
- package/dist/deployment/drivers/netlify.d.ts +6 -2
- package/dist/deployment/drivers/netlify.js +114 -12
- package/dist/deployment/drivers/vercel.d.ts +5 -2
- package/dist/deployment/drivers/vercel.js +84 -5
- package/dist/deployment.d.ts +5 -0
- package/dist/deployment.js +34 -0
- package/dist/permissions/modules/validate-access/lib/validate-item-access.js +1 -1
- package/dist/permissions/utils/get-unaliased-field-key.js +9 -1
- package/dist/request/is-denied-ip.js +24 -23
- package/dist/services/authentication.js +27 -22
- package/dist/services/collections.js +1 -0
- package/dist/services/deployment-projects.d.ts +31 -2
- package/dist/services/deployment-projects.js +109 -5
- package/dist/services/deployment-runs.d.ts +19 -1
- package/dist/services/deployment-runs.js +86 -0
- package/dist/services/deployment.d.ts +44 -3
- package/dist/services/deployment.js +263 -15
- package/dist/services/files/utils/get-metadata.js +6 -6
- package/dist/services/files.d.ts +3 -1
- package/dist/services/files.js +26 -3
- package/dist/services/graphql/resolvers/query.js +23 -6
- package/dist/services/graphql/resolvers/system.js +35 -27
- package/dist/services/payload.d.ts +6 -0
- package/dist/services/payload.js +27 -2
- package/dist/services/server.js +1 -1
- package/dist/services/users.js +6 -1
- package/dist/test-utils/README.md +112 -0
- package/dist/test-utils/controllers.d.ts +65 -0
- package/dist/test-utils/controllers.js +100 -0
- package/dist/test-utils/database.d.ts +1 -1
- package/dist/test-utils/database.js +3 -1
- package/dist/utils/get-field-relational-depth.d.ts +13 -0
- package/dist/utils/get-field-relational-depth.js +22 -0
- package/dist/utils/parse-value.d.ts +4 -0
- package/dist/utils/parse-value.js +11 -0
- package/dist/utils/sanitize-query.js +3 -2
- package/dist/utils/split-fields.d.ts +4 -0
- package/dist/utils/split-fields.js +32 -0
- package/dist/utils/validate-query.js +2 -1
- package/package.json +36 -36
- package/dist/auth/utils/is-login-redirect-allowed.d.ts +0 -7
- package/dist/auth/utils/is-login-redirect-allowed.js +0 -39
|
@@ -3,6 +3,7 @@ import { convertToModelMessages, stepCountIs, streamText, } from 'ai';
|
|
|
3
3
|
import { buildProviderConfigs, createAIProviderRegistry, getProviderOptions, } from '../../providers/index.js';
|
|
4
4
|
import { SYSTEM_PROMPT } from '../constants/system-prompt.js';
|
|
5
5
|
import { formatContextForSystemPrompt } from '../utils/format-context.js';
|
|
6
|
+
import { transformFilePartsForProvider } from './transform-file-parts.js';
|
|
6
7
|
export const createUiStream = async (messages, { provider, model, tools, aiSettings, systemPrompt, context, onUsage }) => {
|
|
7
8
|
const configs = buildProviderConfigs(aiSettings);
|
|
8
9
|
const providerConfig = configs.find((c) => c.type === provider);
|
|
@@ -18,7 +19,7 @@ export const createUiStream = async (messages, { provider, model, tools, aiSetti
|
|
|
18
19
|
const stream = streamText({
|
|
19
20
|
system: baseSystemPrompt,
|
|
20
21
|
model: registry.languageModel(`${provider}:${model}`),
|
|
21
|
-
messages: await convertToModelMessages(messages),
|
|
22
|
+
messages: await convertToModelMessages(transformFilePartsForProvider(messages)),
|
|
22
23
|
stopWhen: [stepCountIs(10)],
|
|
23
24
|
providerOptions,
|
|
24
25
|
tools,
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { UIMessage } from 'ai';
|
|
2
|
+
/**
|
|
3
|
+
* Transforms UIMessage file parts to use provider file_id instead of display URL.
|
|
4
|
+
*
|
|
5
|
+
* The frontend sends files with:
|
|
6
|
+
* - url: display URL for UI rendering (blob: or /assets/ URL)
|
|
7
|
+
* - providerMetadata.directus.fileId: the actual provider file ID
|
|
8
|
+
*
|
|
9
|
+
* This function replaces the url with the fileId so the AI SDK can use it
|
|
10
|
+
* with the provider's native file handling.
|
|
11
|
+
*/
|
|
12
|
+
export declare function transformFilePartsForProvider(messages: UIMessage[]): UIMessage[];
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { useLogger } from '../../../logger/index.js';
|
|
2
|
+
function isFileUIPart(part) {
|
|
3
|
+
return typeof part === 'object' && part !== null && part.type === 'file';
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Transforms UIMessage file parts to use provider file_id instead of display URL.
|
|
7
|
+
*
|
|
8
|
+
* The frontend sends files with:
|
|
9
|
+
* - url: display URL for UI rendering (blob: or /assets/ URL)
|
|
10
|
+
* - providerMetadata.directus.fileId: the actual provider file ID
|
|
11
|
+
*
|
|
12
|
+
* This function replaces the url with the fileId so the AI SDK can use it
|
|
13
|
+
* with the provider's native file handling.
|
|
14
|
+
*/
|
|
15
|
+
export function transformFilePartsForProvider(messages) {
|
|
16
|
+
const logger = useLogger();
|
|
17
|
+
return messages.map((msg) => {
|
|
18
|
+
if (!Array.isArray(msg.parts)) {
|
|
19
|
+
return msg;
|
|
20
|
+
}
|
|
21
|
+
const parts = [];
|
|
22
|
+
for (const part of msg.parts) {
|
|
23
|
+
if (!isFileUIPart(part)) {
|
|
24
|
+
parts.push(part);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const fileId = part.providerMetadata?.directus?.fileId;
|
|
28
|
+
if (!fileId) {
|
|
29
|
+
logger.warn('File part missing providerMetadata.directus.fileId, filtering out');
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
parts.push({ ...part, url: fileId });
|
|
33
|
+
}
|
|
34
|
+
return { ...msg, parts };
|
|
35
|
+
});
|
|
36
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { fetchProvider } from '../lib/fetch-provider.js';
|
|
2
|
+
export async function uploadToAnthropic(file, apiKey) {
|
|
3
|
+
const formData = new FormData();
|
|
4
|
+
formData.append('file', new Blob([new Uint8Array(file.data)], { type: file.mimeType }), file.filename);
|
|
5
|
+
const result = (await fetchProvider('https://api.anthropic.com/v1/files', {
|
|
6
|
+
method: 'POST',
|
|
7
|
+
headers: {
|
|
8
|
+
'x-api-key': apiKey,
|
|
9
|
+
'anthropic-version': '2023-06-01',
|
|
10
|
+
'anthropic-beta': 'files-api-2025-04-14',
|
|
11
|
+
},
|
|
12
|
+
body: formData,
|
|
13
|
+
}, 'Anthropic'));
|
|
14
|
+
if (!result.id) {
|
|
15
|
+
throw new Error('Anthropic upload returned unexpected response');
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
provider: 'anthropic',
|
|
19
|
+
fileId: result.id,
|
|
20
|
+
filename: result.filename ?? file.filename,
|
|
21
|
+
mimeType: result.mime_type ?? file.mimeType,
|
|
22
|
+
sizeBytes: result.size_bytes ?? file.data.length,
|
|
23
|
+
expiresAt: null,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { fetchProvider } from '../lib/fetch-provider.js';
|
|
2
|
+
export async function uploadToGoogle(file, apiKey) {
|
|
3
|
+
const baseUrl = 'https://generativelanguage.googleapis.com/upload/v1beta/files';
|
|
4
|
+
// Use a manual fetch here since we need the response headers, not JSON
|
|
5
|
+
const UPLOAD_TIMEOUT = 120_000;
|
|
6
|
+
let startResponse;
|
|
7
|
+
try {
|
|
8
|
+
startResponse = await fetch(baseUrl, {
|
|
9
|
+
method: 'POST',
|
|
10
|
+
headers: {
|
|
11
|
+
'x-goog-api-key': apiKey,
|
|
12
|
+
'X-Goog-Upload-Protocol': 'resumable',
|
|
13
|
+
'X-Goog-Upload-Command': 'start',
|
|
14
|
+
'X-Goog-Upload-Header-Content-Length': String(file.data.length),
|
|
15
|
+
'X-Goog-Upload-Header-Content-Type': file.mimeType,
|
|
16
|
+
'Content-Type': 'application/json',
|
|
17
|
+
},
|
|
18
|
+
body: JSON.stringify({ file: { display_name: file.filename } }),
|
|
19
|
+
signal: AbortSignal.timeout(UPLOAD_TIMEOUT),
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError')) {
|
|
24
|
+
throw new Error(`Google upload timed out after ${UPLOAD_TIMEOUT / 1000}s`);
|
|
25
|
+
}
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
if (!startResponse.ok) {
|
|
29
|
+
const text = await startResponse.text().catch(() => `HTTP ${startResponse.status}`);
|
|
30
|
+
throw new Error(`Google upload init failed: ${text}`);
|
|
31
|
+
}
|
|
32
|
+
const uploadUrl = startResponse.headers.get('X-Goog-Upload-URL');
|
|
33
|
+
if (!uploadUrl) {
|
|
34
|
+
throw new Error('Google upload init did not return upload URL');
|
|
35
|
+
}
|
|
36
|
+
const result = (await fetchProvider(uploadUrl, {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
headers: {
|
|
39
|
+
'X-Goog-Upload-Command': 'upload, finalize',
|
|
40
|
+
'X-Goog-Upload-Offset': '0',
|
|
41
|
+
'Content-Type': file.mimeType,
|
|
42
|
+
},
|
|
43
|
+
body: new Uint8Array(file.data),
|
|
44
|
+
}, 'Google'));
|
|
45
|
+
const fileData = result.file;
|
|
46
|
+
if (!fileData?.uri) {
|
|
47
|
+
throw new Error('Google upload returned unexpected response');
|
|
48
|
+
}
|
|
49
|
+
const parsedSize = parseInt(fileData.sizeBytes ?? '', 10);
|
|
50
|
+
return {
|
|
51
|
+
provider: 'google',
|
|
52
|
+
fileId: fileData.uri,
|
|
53
|
+
filename: fileData.displayName ?? file.filename,
|
|
54
|
+
mimeType: fileData.mimeType ?? file.mimeType,
|
|
55
|
+
sizeBytes: Number.isNaN(parsedSize) ? file.data.length : parsedSize,
|
|
56
|
+
expiresAt: fileData.expirationTime ?? null,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { fetchProvider } from '../lib/fetch-provider.js';
|
|
2
|
+
export async function uploadToOpenAI(file, apiKey) {
|
|
3
|
+
const formData = new FormData();
|
|
4
|
+
formData.append('file', new Blob([new Uint8Array(file.data)], { type: file.mimeType }), file.filename);
|
|
5
|
+
formData.append('purpose', 'user_data');
|
|
6
|
+
const result = (await fetchProvider('https://api.openai.com/v1/files', {
|
|
7
|
+
method: 'POST',
|
|
8
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
9
|
+
body: formData,
|
|
10
|
+
}, 'OpenAI'));
|
|
11
|
+
if (!result.id) {
|
|
12
|
+
throw new Error('OpenAI upload returned unexpected response');
|
|
13
|
+
}
|
|
14
|
+
return {
|
|
15
|
+
provider: 'openai',
|
|
16
|
+
fileId: result.id,
|
|
17
|
+
filename: file.filename,
|
|
18
|
+
mimeType: file.mimeType,
|
|
19
|
+
sizeBytes: result.bytes ?? file.data.length,
|
|
20
|
+
expiresAt: null,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { AI_ALLOWED_MIME_TYPES } from '@directus/ai';
|
|
2
|
+
import { ForbiddenError, InvalidPayloadError } from '@directus/errors';
|
|
3
|
+
import Busboy from 'busboy';
|
|
4
|
+
import { useLogger } from '../../../logger/index.js';
|
|
5
|
+
import { uploadToProvider } from '../lib/upload-to-provider.js';
|
|
6
|
+
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
|
|
7
|
+
const ALLOWED_MIME_TYPES = new Set(AI_ALLOWED_MIME_TYPES);
|
|
8
|
+
const SUPPORTED_PROVIDERS = new Set(['openai', 'anthropic', 'google']);
|
|
9
|
+
function isSupportedProvider(provider) {
|
|
10
|
+
return SUPPORTED_PROVIDERS.has(provider);
|
|
11
|
+
}
|
|
12
|
+
async function parseMultipart(headers, stream) {
|
|
13
|
+
const contentType = headers['content-type'];
|
|
14
|
+
if (!contentType || !contentType.toLowerCase().startsWith('multipart/')) {
|
|
15
|
+
throw new InvalidPayloadError({ reason: 'Expected multipart/form-data content type' });
|
|
16
|
+
}
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
let file;
|
|
19
|
+
let provider;
|
|
20
|
+
let settled = false;
|
|
21
|
+
const safeReject = (error) => {
|
|
22
|
+
if (settled)
|
|
23
|
+
return;
|
|
24
|
+
settled = true;
|
|
25
|
+
reject(error);
|
|
26
|
+
};
|
|
27
|
+
const bb = Busboy({
|
|
28
|
+
headers: headers,
|
|
29
|
+
limits: { fileSize: MAX_FILE_SIZE, files: 1 },
|
|
30
|
+
});
|
|
31
|
+
bb.on('file', (_name, fileStream, info) => {
|
|
32
|
+
const chunks = [];
|
|
33
|
+
let exceeded = false;
|
|
34
|
+
fileStream.on('data', (chunk) => chunks.push(chunk));
|
|
35
|
+
fileStream.on('limit', () => {
|
|
36
|
+
exceeded = true;
|
|
37
|
+
fileStream.destroy();
|
|
38
|
+
safeReject(new InvalidPayloadError({ reason: `File exceeds maximum size of ${MAX_FILE_SIZE / (1024 * 1024)}MB` }));
|
|
39
|
+
});
|
|
40
|
+
fileStream.on('close', () => {
|
|
41
|
+
if (exceeded)
|
|
42
|
+
return;
|
|
43
|
+
file = {
|
|
44
|
+
filename: info.filename || 'file',
|
|
45
|
+
mimeType: info.mimeType,
|
|
46
|
+
data: Buffer.concat(chunks),
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
fileStream.on('error', safeReject);
|
|
50
|
+
});
|
|
51
|
+
bb.on('field', (name, value) => {
|
|
52
|
+
if (name === 'provider')
|
|
53
|
+
provider = value;
|
|
54
|
+
});
|
|
55
|
+
bb.on('close', () => {
|
|
56
|
+
if (settled)
|
|
57
|
+
return;
|
|
58
|
+
settled = true;
|
|
59
|
+
resolve({ file, provider });
|
|
60
|
+
});
|
|
61
|
+
bb.on('error', safeReject);
|
|
62
|
+
stream.pipe(bb);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
export const aiFileUploadHandler = async (req, res, next) => {
|
|
66
|
+
const logger = useLogger();
|
|
67
|
+
try {
|
|
68
|
+
if (!req.accountability?.app) {
|
|
69
|
+
throw new ForbiddenError();
|
|
70
|
+
}
|
|
71
|
+
const aiSettings = res.locals['ai']?.settings;
|
|
72
|
+
if (!aiSettings) {
|
|
73
|
+
throw new InvalidPayloadError({ reason: 'AI settings not loaded' });
|
|
74
|
+
}
|
|
75
|
+
const { file, provider } = await parseMultipart(req.headers, req);
|
|
76
|
+
if (!file) {
|
|
77
|
+
throw new InvalidPayloadError({ reason: 'No file provided' });
|
|
78
|
+
}
|
|
79
|
+
if (!provider) {
|
|
80
|
+
throw new InvalidPayloadError({ reason: 'No provider specified' });
|
|
81
|
+
}
|
|
82
|
+
if (!isSupportedProvider(provider)) {
|
|
83
|
+
throw new InvalidPayloadError({
|
|
84
|
+
reason: provider === 'openai-compatible'
|
|
85
|
+
? 'File uploads not supported for openai-compatible provider'
|
|
86
|
+
: `Unsupported provider: ${provider}`,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
if (!ALLOWED_MIME_TYPES.has(file.mimeType)) {
|
|
90
|
+
throw new InvalidPayloadError({ reason: `Unsupported file type: ${file.mimeType}` });
|
|
91
|
+
}
|
|
92
|
+
const result = await uploadToProvider(file, provider, aiSettings);
|
|
93
|
+
res.json(result);
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
if (error instanceof Error && !(error instanceof ForbiddenError) && !(error instanceof InvalidPayloadError)) {
|
|
97
|
+
logger.error(error, 'AI file upload failed');
|
|
98
|
+
}
|
|
99
|
+
next(error);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function fetchProvider(url: string, options: RequestInit, providerName: string): Promise<unknown>;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const UPLOAD_TIMEOUT = 120_000;
|
|
2
|
+
export async function fetchProvider(url, options, providerName) {
|
|
3
|
+
let response;
|
|
4
|
+
try {
|
|
5
|
+
response = await fetch(url, { ...options, signal: AbortSignal.timeout(UPLOAD_TIMEOUT) });
|
|
6
|
+
}
|
|
7
|
+
catch (error) {
|
|
8
|
+
if (error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError')) {
|
|
9
|
+
throw new Error(`${providerName} upload timed out after ${UPLOAD_TIMEOUT / 1000}s`);
|
|
10
|
+
}
|
|
11
|
+
throw error;
|
|
12
|
+
}
|
|
13
|
+
if (!response.ok) {
|
|
14
|
+
const text = await response.text().catch(() => `HTTP ${response.status}`);
|
|
15
|
+
throw new Error(`${providerName} upload failed: ${text}`);
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
return await response.json();
|
|
19
|
+
}
|
|
20
|
+
catch (cause) {
|
|
21
|
+
throw new Error(`${providerName} upload succeeded but returned invalid response`, { cause });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ProviderFileRef, StandardProviderType } from '@directus/ai';
|
|
2
|
+
import type { AISettings } from '../../providers/types.js';
|
|
3
|
+
import type { UploadedFile } from '../types.js';
|
|
4
|
+
export declare function uploadToProvider(file: UploadedFile, provider: StandardProviderType, settings: AISettings): Promise<ProviderFileRef>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { InvalidPayloadError } from '@directus/errors';
|
|
2
|
+
import { uploadToAnthropic, uploadToGoogle, uploadToOpenAI } from '../adapters/index.js';
|
|
3
|
+
export async function uploadToProvider(file, provider, settings) {
|
|
4
|
+
switch (provider) {
|
|
5
|
+
case 'openai': {
|
|
6
|
+
if (!settings.openaiApiKey) {
|
|
7
|
+
throw new InvalidPayloadError({ reason: 'OpenAI API key not configured' });
|
|
8
|
+
}
|
|
9
|
+
return uploadToOpenAI(file, settings.openaiApiKey);
|
|
10
|
+
}
|
|
11
|
+
case 'anthropic': {
|
|
12
|
+
if (!settings.anthropicApiKey) {
|
|
13
|
+
throw new InvalidPayloadError({ reason: 'Anthropic API key not configured' });
|
|
14
|
+
}
|
|
15
|
+
return uploadToAnthropic(file, settings.anthropicApiKey);
|
|
16
|
+
}
|
|
17
|
+
case 'google': {
|
|
18
|
+
if (!settings.googleApiKey) {
|
|
19
|
+
throw new InvalidPayloadError({ reason: 'Google API key not configured' });
|
|
20
|
+
}
|
|
21
|
+
return uploadToGoogle(file, settings.googleApiKey);
|
|
22
|
+
}
|
|
23
|
+
default:
|
|
24
|
+
throw new InvalidPayloadError({ reason: `Provider ${provider} does not support file uploads` });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const aiFilesRouter: import("express-serve-static-core").Router;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import asyncHandler from '../../utils/async-handler.js';
|
|
3
|
+
import { loadSettings } from '../chat/middleware/load-settings.js';
|
|
4
|
+
import { aiFileUploadHandler } from './controllers/upload.js';
|
|
5
|
+
export const aiFilesRouter = Router().post('/', asyncHandler(loadSettings), asyncHandler(aiFileUploadHandler));
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creates an Anthropic provider with file_id support.
|
|
3
|
+
*
|
|
4
|
+
* The AI SDK's @ai-sdk/anthropic provider doesn't support Anthropic's Files API file_id.
|
|
5
|
+
* This wrapper intercepts the HTTP request and transforms base64 sources that contain
|
|
6
|
+
* a file_id marker into the native Anthropic file source format.
|
|
7
|
+
*
|
|
8
|
+
* When the AI SDK converts a FileUIPart with url=file_id, it creates a base64 source
|
|
9
|
+
* with the file_id as the data (since it's not a valid URL or base64). We detect this
|
|
10
|
+
* pattern and transform it to use the native file source type.
|
|
11
|
+
*/
|
|
12
|
+
export declare function createAnthropicWithFileSupport(apiKey: string): import("@ai-sdk/anthropic").AnthropicProvider;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { createAnthropic } from '@ai-sdk/anthropic';
|
|
2
|
+
import { useLogger } from '../../logger/index.js';
|
|
3
|
+
/**
|
|
4
|
+
* Creates an Anthropic provider with file_id support.
|
|
5
|
+
*
|
|
6
|
+
* The AI SDK's @ai-sdk/anthropic provider doesn't support Anthropic's Files API file_id.
|
|
7
|
+
* This wrapper intercepts the HTTP request and transforms base64 sources that contain
|
|
8
|
+
* a file_id marker into the native Anthropic file source format.
|
|
9
|
+
*
|
|
10
|
+
* When the AI SDK converts a FileUIPart with url=file_id, it creates a base64 source
|
|
11
|
+
* with the file_id as the data (since it's not a valid URL or base64). We detect this
|
|
12
|
+
* pattern and transform it to use the native file source type.
|
|
13
|
+
*/
|
|
14
|
+
export function createAnthropicWithFileSupport(apiKey) {
|
|
15
|
+
return createAnthropic({
|
|
16
|
+
apiKey,
|
|
17
|
+
fetch: async (url, options) => {
|
|
18
|
+
if (!options?.body || typeof options.body !== 'string') {
|
|
19
|
+
return fetch(url, options);
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
const body = JSON.parse(options.body);
|
|
23
|
+
if (!body.messages) {
|
|
24
|
+
return fetch(url, options);
|
|
25
|
+
}
|
|
26
|
+
const { messages, hasFileIds } = transformMessagesForFileId(body.messages);
|
|
27
|
+
body.messages = messages;
|
|
28
|
+
const headersObj = {};
|
|
29
|
+
if (options.headers instanceof Headers) {
|
|
30
|
+
options.headers.forEach((value, key) => {
|
|
31
|
+
headersObj[key] = value;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
Object.assign(headersObj, options.headers);
|
|
36
|
+
}
|
|
37
|
+
if (hasFileIds) {
|
|
38
|
+
const existing = headersObj['anthropic-beta'];
|
|
39
|
+
const betaFlag = 'files-api-2025-04-14';
|
|
40
|
+
if (!existing?.includes(betaFlag)) {
|
|
41
|
+
headersObj['anthropic-beta'] = existing ? `${existing},${betaFlag}` : betaFlag;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return fetch(url, {
|
|
45
|
+
...options,
|
|
46
|
+
headers: headersObj,
|
|
47
|
+
body: JSON.stringify(body),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
const logger = useLogger();
|
|
52
|
+
logger.error('Anthropic file support: could not parse request body');
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Transforms messages to use file_id source type where applicable.
|
|
60
|
+
*
|
|
61
|
+
* The AI SDK converts FileUIPart.url to base64 source data. When url is a file_id
|
|
62
|
+
* (starts with "file_"), the data field contains the file_id string.
|
|
63
|
+
* We detect this and convert to native Anthropic file source format.
|
|
64
|
+
*/
|
|
65
|
+
function transformMessagesForFileId(messages) {
|
|
66
|
+
let hasFileIds = false;
|
|
67
|
+
const transformedMessages = messages.map((msg) => {
|
|
68
|
+
if (!msg.content || !Array.isArray(msg.content)) {
|
|
69
|
+
return msg;
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
...msg,
|
|
73
|
+
content: msg.content.map((block) => {
|
|
74
|
+
// Check if this is an image or document with base64 source
|
|
75
|
+
if ((block.type === 'image' || block.type === 'document') &&
|
|
76
|
+
block.source?.type === 'base64' &&
|
|
77
|
+
typeof block.source.data === 'string' &&
|
|
78
|
+
block.source.data.startsWith('file_')) {
|
|
79
|
+
const fileId = block.source.data;
|
|
80
|
+
hasFileIds = true;
|
|
81
|
+
return {
|
|
82
|
+
...block,
|
|
83
|
+
source: {
|
|
84
|
+
type: 'file',
|
|
85
|
+
file_id: fileId,
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return block;
|
|
90
|
+
}),
|
|
91
|
+
};
|
|
92
|
+
});
|
|
93
|
+
return { messages: transformedMessages, hasFileIds };
|
|
94
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { createAnthropic } from '@ai-sdk/anthropic';
|
|
2
1
|
import { createGoogleGenerativeAI } from '@ai-sdk/google';
|
|
3
2
|
import { createOpenAI } from '@ai-sdk/openai';
|
|
4
3
|
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
|
|
5
4
|
import { createProviderRegistry } from 'ai';
|
|
5
|
+
import { createAnthropicWithFileSupport } from './anthropic-file-support.js';
|
|
6
6
|
export function buildProviderConfigs(settings) {
|
|
7
7
|
const configs = [];
|
|
8
8
|
if (settings.openaiApiKey) {
|
|
@@ -40,17 +40,14 @@ export function createAIProviderRegistry(configs, settings) {
|
|
|
40
40
|
providers['openai'] = createOpenAI({ apiKey: config.apiKey });
|
|
41
41
|
break;
|
|
42
42
|
case 'anthropic':
|
|
43
|
-
providers['anthropic'] =
|
|
43
|
+
providers['anthropic'] = createAnthropicWithFileSupport(config.apiKey);
|
|
44
44
|
break;
|
|
45
45
|
case 'google':
|
|
46
46
|
providers['google'] = createGoogleGenerativeAI({ apiKey: config.apiKey });
|
|
47
47
|
break;
|
|
48
48
|
case 'openai-compatible':
|
|
49
49
|
if (config.baseUrl) {
|
|
50
|
-
const customHeaders = settings?.openaiCompatibleHeaders?.
|
|
51
|
-
acc[header] = value;
|
|
52
|
-
return acc;
|
|
53
|
-
}, {}) ?? {};
|
|
50
|
+
const customHeaders = Object.fromEntries(settings?.openaiCompatibleHeaders?.map(({ header, value }) => [header, value]) ?? []);
|
|
54
51
|
providers['openai-compatible'] = createOpenAICompatible({
|
|
55
52
|
name: settings?.openaiCompatibleName ?? 'openai-compatible',
|
|
56
53
|
apiKey: config.apiKey,
|
|
@@ -29,7 +29,7 @@ export declare const FieldsValidateSchema: z.ZodDiscriminatedUnion<[z.ZodObject<
|
|
|
29
29
|
action: z.ZodLiteral<"update">;
|
|
30
30
|
data: z.ZodArray<z.ZodObject<{
|
|
31
31
|
field: z.ZodString;
|
|
32
|
-
type: z.ZodString
|
|
32
|
+
type: z.ZodOptional<z.ZodString>;
|
|
33
33
|
name: z.ZodOptional<z.ZodString>;
|
|
34
34
|
children: z.ZodOptional<z.ZodUnion<readonly [z.ZodArray<z.ZodRecord<z.ZodString, z.ZodAny>>, z.ZodNull]>>;
|
|
35
35
|
collection: z.ZodOptional<z.ZodString>;
|
|
@@ -51,7 +51,7 @@ export declare const FieldsInputSchema: z.ZodObject<{
|
|
|
51
51
|
collection: z.ZodOptional<z.ZodString>;
|
|
52
52
|
field: z.ZodOptional<z.ZodString>;
|
|
53
53
|
data: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
54
|
-
field: z.ZodOptional<z.ZodString
|
|
54
|
+
field: z.ZodNonOptional<z.ZodOptional<z.ZodString>>;
|
|
55
55
|
type: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
56
56
|
name: z.ZodOptional<z.ZodOptional<z.ZodString>>;
|
|
57
57
|
collection: z.ZodOptional<z.ZodOptional<z.ZodString>>;
|
|
@@ -87,7 +87,7 @@ export declare const fields: import("../types.js").ToolConfig<{
|
|
|
87
87
|
action: "update";
|
|
88
88
|
data: {
|
|
89
89
|
field: string;
|
|
90
|
-
type
|
|
90
|
+
type?: string | undefined;
|
|
91
91
|
name?: string | undefined;
|
|
92
92
|
children?: Record<string, any>[] | null | undefined;
|
|
93
93
|
collection?: string | undefined;
|
|
@@ -28,7 +28,7 @@ export const FieldsValidateSchema = z.discriminatedUnion('action', [
|
|
|
28
28
|
}),
|
|
29
29
|
FieldsBaseValidateSchema.extend({
|
|
30
30
|
action: z.literal('update'),
|
|
31
|
-
data: z.array(RawFieldItemValidateSchema),
|
|
31
|
+
data: z.array(RawFieldItemValidateSchema.partial({ type: true })),
|
|
32
32
|
}),
|
|
33
33
|
FieldsBaseValidateSchema.extend({
|
|
34
34
|
action: z.literal('delete'),
|
|
@@ -38,11 +38,17 @@ export const FieldsValidateSchema = z.discriminatedUnion('action', [
|
|
|
38
38
|
export const FieldsInputSchema = z.object({
|
|
39
39
|
action: z.enum(['read', 'create', 'update', 'delete']).describe('The operation to perform'),
|
|
40
40
|
collection: z.string().describe('The name of the collection').optional(),
|
|
41
|
-
field: z
|
|
41
|
+
field: z
|
|
42
|
+
.string()
|
|
43
|
+
.describe('The name of the field. Required for delete. Optional for read (omit to read all fields). Do not use for create or update.')
|
|
44
|
+
.optional(),
|
|
42
45
|
data: z
|
|
43
46
|
.array(FieldItemInputSchema.extend({
|
|
44
47
|
children: RawFieldItemInputSchema.shape.children,
|
|
45
|
-
})
|
|
48
|
+
})
|
|
49
|
+
.partial()
|
|
50
|
+
.required({ field: true }))
|
|
51
|
+
.describe('Array of field objects for create/update actions. Each object must include "field" (the field name).')
|
|
46
52
|
.optional(),
|
|
47
53
|
});
|
|
48
54
|
export const fields = defineTool({
|