feat(core): Add endpoint to create free AI credits (#12362)

This commit is contained in:
Ricardo Espinoza 2024-12-27 09:46:57 -05:00 committed by GitHub
parent c00b95e08f
commit ac4e042231
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 258 additions and 34 deletions

View file

@ -0,0 +1,32 @@
import { nanoId } from 'minifaker';
import { AiFreeCreditsRequestDto } from '../ai-free-credits-request.dto';
import 'minifaker/locales/en';
describe('AiChatRequestDto', () => {
it('should succeed if projectId is a valid nanoid', () => {
const validRequest = {
projectId: nanoId.nanoid(),
};
const result = AiFreeCreditsRequestDto.safeParse(validRequest);
expect(result.success).toBe(true);
});
it('should succeed if no projectId is sent', () => {
const result = AiFreeCreditsRequestDto.safeParse({});
expect(result.success).toBe(true);
});
it('should fail is projectId invalid value', () => {
const validRequest = {
projectId: '',
};
const result = AiFreeCreditsRequestDto.safeParse(validRequest);
expect(result.success).toBe(false);
});
});

View file

@ -0,0 +1,6 @@
import { z } from 'zod';
import { Z } from 'zod-class';
export class AiFreeCreditsRequestDto extends Z.class({
projectId: z.string().min(1).optional(),
}) {}

View file

@ -1,6 +1,7 @@
export { AiAskRequestDto } from './ai/ai-ask-request.dto';
export { AiChatRequestDto } from './ai/ai-chat-request.dto';
export { AiApplySuggestionRequestDto } from './ai/ai-apply-suggestion-request.dto';
export { AiFreeCreditsRequestDto } from './ai/ai-free-credits-request.dto';
export { LoginRequestDto } from './auth/login-request.dto';
export { ResolveSignupTokenQueryDto } from './auth/resolve-signup-token-query.dto';

View file

@ -94,7 +94,7 @@
"@n8n/permissions": "workspace:*",
"@n8n/task-runner": "workspace:*",
"@n8n/typeorm": "0.3.20-12",
"@n8n_io/ai-assistant-sdk": "1.12.0",
"@n8n_io/ai-assistant-sdk": "1.13.0",
"@n8n_io/license-sdk": "2.13.1",
"@oclif/core": "4.0.7",
"@rudderstack/rudder-sdk-node": "2.0.9",

View file

@ -174,3 +174,6 @@ export const WsStatusCodes = {
CloseAbnormal: 1006,
CloseInvalidData: 1007,
} as const;
export const FREE_AI_CREDITS_CREDENTIAL_NAME = 'n8n free OpenAI API credits';
export const OPEN_AI_API_CREDENTIAL_TYPE = 'openAiApi';

View file

@ -14,7 +14,8 @@ import { AiController, type FlushableResponse } from '../ai.controller';
describe('AiController', () => {
const aiService = mock<AiService>();
const controller = new AiController(aiService);
const controller = new AiController(aiService, mock(), mock());
const request = mock<AuthenticatedRequest>({
user: { id: 'user123' },

View file

@ -1,19 +1,32 @@
import { AiChatRequestDto, AiApplySuggestionRequestDto, AiAskRequestDto } from '@n8n/api-types';
import {
AiChatRequestDto,
AiApplySuggestionRequestDto,
AiAskRequestDto,
AiFreeCreditsRequestDto,
} from '@n8n/api-types';
import type { AiAssistantSDK } from '@n8n_io/ai-assistant-sdk';
import { Response } from 'express';
import { strict as assert } from 'node:assert';
import { WritableStream } from 'node:stream/web';
import { FREE_AI_CREDITS_CREDENTIAL_NAME, OPEN_AI_API_CREDENTIAL_TYPE } from '@/constants';
import { CredentialsService } from '@/credentials/credentials.service';
import { Body, Post, RestController } from '@/decorators';
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
import type { CredentialRequest } from '@/requests';
import { AuthenticatedRequest } from '@/requests';
import { AiService } from '@/services/ai.service';
import { UserService } from '@/services/user.service';
export type FlushableResponse = Response & { flush: () => void };
@RestController('/ai')
export class AiController {
constructor(private readonly aiService: AiService) {}
constructor(
private readonly aiService: AiService,
private readonly credentialsService: CredentialsService,
private readonly userService: UserService,
) {}
@Post('/chat', { rateLimit: { limit: 100 } })
async chat(req: AuthenticatedRequest, res: FlushableResponse, @Body payload: AiChatRequestDto) {
@ -64,4 +77,36 @@ export class AiController {
throw new InternalServerError(e.message, e);
}
}
@Post('/free-credits')
async aiCredits(req: AuthenticatedRequest, _: Response, @Body payload: AiFreeCreditsRequestDto) {
try {
const aiCredits = await this.aiService.createFreeAiCredits(req.user);
const credentialProperties: CredentialRequest.CredentialProperties = {
name: FREE_AI_CREDITS_CREDENTIAL_NAME,
type: OPEN_AI_API_CREDENTIAL_TYPE,
data: {
apiKey: aiCredits.apiKey,
url: aiCredits.url,
},
isManaged: true,
projectId: payload?.projectId,
};
const newCredential = await this.credentialsService.createCredential(
credentialProperties,
req.user,
);
await this.userService.updateSettings(req.user.id, {
userClaimedAiCredits: true,
});
return newCredential;
} catch (e) {
assert(e instanceof Error);
throw new InternalServerError(e.message, e);
}
}
}

View file

@ -186,6 +186,10 @@ export class CredentialsController {
);
}
if (credential.isManaged) {
throw new BadRequestError('Managed credentials cannot be updated');
}
const decryptedData = this.credentialsService.decrypt(credential);
const preparedCredentialData = await this.credentialsService.prepareUpdateData(
req.body,

View file

@ -196,6 +196,7 @@ export class CredentialsService {
name: c.name,
type: c.type,
scopes: c.scopes,
isManaged: c.isManaged,
}));
}

View file

@ -41,7 +41,7 @@ export class CredentialsRepository extends Repository<CredentialsEntity> {
type Select = Array<keyof CredentialsEntity>;
const defaultRelations = ['shared', 'shared.project'];
const defaultSelect: Select = ['id', 'name', 'type', 'createdAt', 'updatedAt'];
const defaultSelect: Select = ['id', 'name', 'type', 'isManaged', 'createdAt', 'updatedAt'];
if (!listQueryOptions) return { select: defaultSelect, relations: defaultRelations };

View file

@ -142,6 +142,7 @@ export declare namespace CredentialRequest {
type: string;
data: ICredentialDataDecryptedObject;
projectId?: string;
isManaged?: boolean;
}>;
type Create = AuthenticatedRequest<{}, {}, CredentialProperties>;

View file

@ -22,6 +22,7 @@ export class AiService {
async init() {
const aiAssistantEnabled = this.licenseService.isAiAssistantEnabled();
if (!aiAssistantEnabled) {
return;
}
@ -66,4 +67,13 @@ export class AiService {
return await this.client.askAi(payload, { id: user.id });
}
async createFreeAiCredits(user: IUser) {
if (!this.client) {
await this.init();
}
assert(this.client, 'Assistant client not setup');
return await this.client.generateAiCreditsCredentials(user);
}
}

View file

@ -0,0 +1,99 @@
import { randomUUID } from 'crypto';
import { mock } from 'jest-mock-extended';
import { Container } from 'typedi';
import { FREE_AI_CREDITS_CREDENTIAL_NAME, OPEN_AI_API_CREDENTIAL_TYPE } from '@/constants';
import type { Project } from '@/databases/entities/project';
import type { User } from '@/databases/entities/user';
import { CredentialsRepository } from '@/databases/repositories/credentials.repository';
import { ProjectRepository } from '@/databases/repositories/project.repository';
import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository';
import { UserRepository } from '@/databases/repositories/user.repository';
import { AiService } from '@/services/ai.service';
import { createOwner } from '../shared/db/users';
import * as testDb from '../shared/test-db';
import type { SuperAgentTest } from '../shared/types';
import { setupTestServer } from '../shared/utils';
const createAiCreditsResponse = {
apiKey: randomUUID(),
url: 'https://api.openai.com',
};
Container.set(
AiService,
mock<AiService>({
createFreeAiCredits: async () => createAiCreditsResponse,
}),
);
const testServer = setupTestServer({ endpointGroups: ['ai'] });
let owner: User;
let ownerPersonalProject: Project;
let authOwnerAgent: SuperAgentTest;
beforeEach(async () => {
await testDb.truncate(['SharedCredentials', 'Credentials']);
owner = await createOwner();
ownerPersonalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(
owner.id,
);
authOwnerAgent = testServer.authAgentFor(owner);
});
describe('POST /ai/free-credits', () => {
test('should create OpenAI managed credential', async () => {
// Act
const response = await authOwnerAgent.post('/ai/free-credits').send({
projectId: ownerPersonalProject.id,
});
// Assert
expect(response.statusCode).toBe(200);
const { id, name, type, data: encryptedData, scopes } = response.body.data;
expect(name).toBe(FREE_AI_CREDITS_CREDENTIAL_NAME);
expect(type).toBe(OPEN_AI_API_CREDENTIAL_TYPE);
expect(encryptedData).not.toBe(createAiCreditsResponse);
expect(scopes).toEqual(
[
'credential:create',
'credential:delete',
'credential:list',
'credential:move',
'credential:read',
'credential:share',
'credential:update',
].sort(),
);
const credential = await Container.get(CredentialsRepository).findOneByOrFail({ id });
expect(credential.name).toBe(FREE_AI_CREDITS_CREDENTIAL_NAME);
expect(credential.type).toBe(OPEN_AI_API_CREDENTIAL_TYPE);
expect(credential.data).not.toBe(createAiCreditsResponse);
expect(credential.isManaged).toBe(true);
const sharedCredential = await Container.get(SharedCredentialsRepository).findOneOrFail({
relations: { project: true, credentials: true },
where: { credentialsId: credential.id },
});
expect(sharedCredential.project.id).toBe(ownerPersonalProject.id);
expect(sharedCredential.credentials.name).toBe(FREE_AI_CREDITS_CREDENTIAL_NAME);
expect(sharedCredential.credentials.isManaged).toBe(true);
const user = await Container.get(UserRepository).findOneOrFail({ where: { id: owner.id } });
expect(user.settings?.userClaimedAiCredits).toBe(true);
});
});

View file

@ -87,6 +87,7 @@ describe('GET /credentials', () => {
validateMainCredentialData(credential);
expect('data' in credential).toBe(false);
expect(savedCredentialsIds).toContain(credential.id);
expect('isManaged' in credential).toBe(true);
});
});
@ -1035,6 +1036,19 @@ describe('PATCH /credentials/:id', () => {
expect(response.statusCode).toBe(403);
});
test('should fail with a 400 is credential is managed', async () => {
const { id } = await saveCredential(randomCredentialPayload({ isManaged: true }), {
user: owner,
role: 'credential:owner',
});
const response = await authOwnerAgent
.patch(`/credentials/${id}`)
.send(randomCredentialPayload());
expect(response.statusCode).toBe(400);
});
});
describe('GET /credentials/new', () => {
@ -1188,10 +1202,11 @@ const INVALID_PAYLOADS = [
];
function validateMainCredentialData(credential: ListQuery.Credentials.WithOwnedByAndSharedWith) {
const { name, type, sharedWithProjects, homeProject } = credential;
const { name, type, sharedWithProjects, homeProject, isManaged } = credential;
expect(typeof name).toBe('string');
expect(typeof type).toBe('string');
expect(typeof isManaged).toBe('boolean');
if (sharedWithProjects) {
expect(Array.isArray(sharedWithProjects)).toBe(true);

View file

@ -37,10 +37,13 @@ const randomTopLevelDomain = () => chooseRandomly(POPULAR_TOP_LEVEL_DOMAINS);
export const randomName = () => randomString(4, 8).toLowerCase();
export const randomCredentialPayload = (): CredentialPayload => ({
export const randomCredentialPayload = ({
isManaged = false,
}: { isManaged?: boolean } = {}): CredentialPayload => ({
name: randomName(),
type: randomName(),
data: { accessToken: randomString(6, 16) },
isManaged,
});
export const uniqueId = () => uuid();

View file

@ -42,7 +42,8 @@ type EndpointGroup =
| 'role'
| 'dynamic-node-parameters'
| 'apiKeys'
| 'evaluation';
| 'evaluation'
| 'ai';
export interface SetupProps {
endpointGroups?: EndpointGroup[];
@ -68,6 +69,7 @@ export type CredentialPayload = {
name: string;
type: string;
data: ICredentialDataDecryptedObject;
isManaged?: boolean;
};
export type SaveCredentialFunction = (

View file

@ -283,6 +283,9 @@ export const setupTestServer = ({
await import('@/evaluation.ee/test-definitions.controller.ee');
await import('@/evaluation.ee/test-runs.controller.ee');
break;
case 'ai':
await import('@/controllers/ai.controller');
}
}

View file

@ -2783,6 +2783,7 @@ export interface IUserSettings {
allowSSOManualLogin?: boolean;
npsSurvey?: NpsSurveyState;
easyAIWorkflowOnboarded?: boolean;
userClaimedAiCredits?: boolean;
}
export interface IProcessedDataConfig {

View file

@ -434,7 +434,7 @@ importers:
version: 3.666.0(@aws-sdk/client-sts@3.666.0)
'@getzep/zep-cloud':
specifier: 1.0.12
version: 1.0.12(@langchain/core@0.3.19(openai@4.73.1(encoding@0.1.13)(zod@3.23.8)))(encoding@0.1.13)(langchain@0.3.6(e4rnrwhosnp2xiru36mqgdy2bu))
version: 1.0.12(@langchain/core@0.3.19(openai@4.73.1(encoding@0.1.13)(zod@3.23.8)))(encoding@0.1.13)(langchain@0.3.6(4axcxpjbcq5bce7ff6ajxrpp4i))
'@getzep/zep-js':
specifier: 0.9.0
version: 0.9.0
@ -461,7 +461,7 @@ importers:
version: 0.3.1(@aws-sdk/client-sso-oidc@3.666.0(@aws-sdk/client-sts@3.666.0))(@langchain/core@0.3.19(openai@4.73.1(encoding@0.1.13)(zod@3.23.8)))(encoding@0.1.13)
'@langchain/community':
specifier: 0.3.15
version: 0.3.15(vc5hvyy27o4cmm4jplsptc2fqm)
version: 0.3.15(v4qhcw25bevfr6xzz4fnsvjiqe)
'@langchain/core':
specifier: 'catalog:'
version: 0.3.19(openai@4.73.1(encoding@0.1.13)(zod@3.23.8))
@ -548,7 +548,7 @@ importers:
version: 23.0.1
langchain:
specifier: 0.3.6
version: 0.3.6(e4rnrwhosnp2xiru36mqgdy2bu)
version: 0.3.6(4axcxpjbcq5bce7ff6ajxrpp4i)
lodash:
specifier: 'catalog:'
version: 4.17.21
@ -788,8 +788,8 @@ importers:
specifier: 0.3.20-12
version: 0.3.20-12(@sentry/node@8.42.0)(ioredis@5.3.2)(mssql@10.0.2)(mysql2@3.11.0)(pg@8.12.0)(redis@4.6.14)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@18.16.16)(typescript@5.7.2))
'@n8n_io/ai-assistant-sdk':
specifier: 1.12.0
version: 1.12.0
specifier: 1.13.0
version: 1.13.0
'@n8n_io/license-sdk':
specifier: 2.13.1
version: 2.13.1
@ -4326,8 +4326,8 @@ packages:
engines: {node: '>=18.10', pnpm: '>=9.6'}
hasBin: true
'@n8n_io/ai-assistant-sdk@1.12.0':
resolution: {integrity: sha512-ddIGzUn8icxWwl49PLSpl/Gfb0bCIGpqvWtZWqC3GIBeb51Nul6E4e3cIyDYOFlZmWWr/BDKsN0wskm2s/jkdg==}
'@n8n_io/ai-assistant-sdk@1.13.0':
resolution: {integrity: sha512-16kftFTeX3/lBinHJaBK0OL1lB4FpPaUoHX4h25AkvgHvmjUHpWNY2ZtKos0rY89+pkzDsNxMZqSUkeKU45iRg==}
engines: {node: '>=20.15', pnpm: '>=8.14'}
'@n8n_io/license-sdk@2.13.1':
@ -12996,6 +12996,9 @@ packages:
vue-component-type-helpers@2.1.10:
resolution: {integrity: sha512-lfgdSLQKrUmADiSV6PbBvYgQ33KF3Ztv6gP85MfGaGaSGMTXORVaHT1EHfsqCgzRNBstPKYDmvAV9Do5CmJ07A==}
vue-component-type-helpers@2.2.0:
resolution: {integrity: sha512-cYrAnv2me7bPDcg9kIcGwjJiSB6Qyi08+jLDo9yuvoFQjzHiPTzML7RnkJB1+3P6KMsX/KbCD4QE3Tv/knEllw==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
engines: {node: '>=12'}
@ -15678,7 +15681,7 @@ snapshots:
'@gar/promisify@1.1.3':
optional: true
'@getzep/zep-cloud@1.0.12(@langchain/core@0.3.19(openai@4.73.1(encoding@0.1.13)(zod@3.23.8)))(encoding@0.1.13)(langchain@0.3.6(e4rnrwhosnp2xiru36mqgdy2bu))':
'@getzep/zep-cloud@1.0.12(@langchain/core@0.3.19(openai@4.73.1(encoding@0.1.13)(zod@3.23.8)))(encoding@0.1.13)(langchain@0.3.6(4axcxpjbcq5bce7ff6ajxrpp4i))':
dependencies:
form-data: 4.0.0
node-fetch: 2.7.0(encoding@0.1.13)
@ -15687,7 +15690,7 @@ snapshots:
zod: 3.23.8
optionalDependencies:
'@langchain/core': 0.3.19(openai@4.73.1(encoding@0.1.13)(zod@3.23.8))
langchain: 0.3.6(e4rnrwhosnp2xiru36mqgdy2bu)
langchain: 0.3.6(4axcxpjbcq5bce7ff6ajxrpp4i)
transitivePeerDependencies:
- encoding
@ -16151,7 +16154,7 @@ snapshots:
- aws-crt
- encoding
'@langchain/community@0.3.15(vc5hvyy27o4cmm4jplsptc2fqm)':
'@langchain/community@0.3.15(v4qhcw25bevfr6xzz4fnsvjiqe)':
dependencies:
'@ibm-cloud/watsonx-ai': 1.1.2
'@langchain/core': 0.3.19(openai@4.73.1(encoding@0.1.13)(zod@3.23.8))
@ -16161,7 +16164,7 @@ snapshots:
flat: 5.0.2
ibm-cloud-sdk-core: 5.1.0
js-yaml: 4.1.0
langchain: 0.3.6(e4rnrwhosnp2xiru36mqgdy2bu)
langchain: 0.3.6(4axcxpjbcq5bce7ff6ajxrpp4i)
langsmith: 0.2.3(openai@4.73.1(encoding@0.1.13)(zod@3.23.8))
uuid: 10.0.0
zod: 3.23.8
@ -16174,7 +16177,7 @@ snapshots:
'@aws-sdk/client-s3': 3.666.0
'@aws-sdk/credential-provider-node': 3.666.0(@aws-sdk/client-sso-oidc@3.666.0(@aws-sdk/client-sts@3.666.0))(@aws-sdk/client-sts@3.666.0)
'@azure/storage-blob': 12.18.0(encoding@0.1.13)
'@getzep/zep-cloud': 1.0.12(@langchain/core@0.3.19(openai@4.73.1(encoding@0.1.13)(zod@3.23.8)))(encoding@0.1.13)(langchain@0.3.6(e4rnrwhosnp2xiru36mqgdy2bu))
'@getzep/zep-cloud': 1.0.12(@langchain/core@0.3.19(openai@4.73.1(encoding@0.1.13)(zod@3.23.8)))(encoding@0.1.13)(langchain@0.3.6(4axcxpjbcq5bce7ff6ajxrpp4i))
'@getzep/zep-js': 0.9.0
'@google-ai/generativelanguage': 2.6.0(encoding@0.1.13)
'@google-cloud/storage': 7.12.1(encoding@0.1.13)
@ -16546,7 +16549,7 @@ snapshots:
acorn: 8.12.1
acorn-walk: 8.3.4
'@n8n_io/ai-assistant-sdk@1.12.0': {}
'@n8n_io/ai-assistant-sdk@1.13.0': {}
'@n8n_io/license-sdk@2.13.1':
dependencies:
@ -18024,7 +18027,7 @@ snapshots:
ts-dedent: 2.2.0
type-fest: 2.19.0
vue: 3.5.13(typescript@5.7.2)
vue-component-type-helpers: 2.1.10
vue-component-type-helpers: 2.2.0
'@supabase/auth-js@2.65.0':
dependencies:
@ -19458,14 +19461,6 @@ snapshots:
transitivePeerDependencies:
- debug
axios@1.7.4(debug@4.3.7):
dependencies:
follow-redirects: 1.15.6(debug@4.3.7)
form-data: 4.0.0
proxy-from-env: 1.1.0
transitivePeerDependencies:
- debug
axios@1.7.7:
dependencies:
follow-redirects: 1.15.6(debug@4.3.6)
@ -22317,7 +22312,7 @@ snapshots:
'@types/debug': 4.1.12
'@types/node': 18.16.16
'@types/tough-cookie': 4.0.2
axios: 1.7.4(debug@4.3.7)
axios: 1.7.4
camelcase: 6.3.0
debug: 4.3.7
dotenv: 16.4.5
@ -22327,7 +22322,7 @@ snapshots:
isstream: 0.1.2
jsonwebtoken: 9.0.2
mime-types: 2.1.35
retry-axios: 2.6.0(axios@1.7.4)
retry-axios: 2.6.0(axios@1.7.4(debug@4.3.7))
tough-cookie: 4.1.3
transitivePeerDependencies:
- supports-color
@ -23326,7 +23321,7 @@ snapshots:
kuler@2.0.0: {}
langchain@0.3.6(e4rnrwhosnp2xiru36mqgdy2bu):
langchain@0.3.6(4axcxpjbcq5bce7ff6ajxrpp4i):
dependencies:
'@langchain/core': 0.3.19(openai@4.73.1(encoding@0.1.13)(zod@3.23.8))
'@langchain/openai': 0.3.14(@langchain/core@0.3.19(openai@4.73.1(encoding@0.1.13)(zod@3.23.8)))(encoding@0.1.13)
@ -25696,7 +25691,7 @@ snapshots:
ret@0.1.15: {}
retry-axios@2.6.0(axios@1.7.4):
retry-axios@2.6.0(axios@1.7.4(debug@4.3.7)):
dependencies:
axios: 1.7.4
@ -27337,6 +27332,8 @@ snapshots:
vue-component-type-helpers@2.1.10: {}
vue-component-type-helpers@2.2.0: {}
vue-demi@0.14.10(vue@3.5.13(typescript@5.7.2)):
dependencies:
vue: 3.5.13(typescript@5.7.2)