-
Learn more about
-
- Gendo
-
+
+
+
+ Terms and conditions
+
+
+
+ More about
+
+ Gendo
+
+
diff --git a/packages/frontend-2/lib/common/generated/gql/gql.ts b/packages/frontend-2/lib/common/generated/gql/gql.ts
index b496cd05f..b5a2ec3c6 100644
--- a/packages/frontend-2/lib/common/generated/gql/gql.ts
+++ b/packages/frontend-2/lib/common/generated/gql/gql.ts
@@ -186,6 +186,7 @@ const documents = {
"\n query DeveloperSettingsAuthorizedApps {\n activeUser {\n id\n authorizedApps {\n id\n description\n name\n author {\n id\n name\n avatar\n }\n }\n }\n }\n": types.DeveloperSettingsAuthorizedAppsDocument,
"\n query SearchProjects($search: String, $onlyWithRoles: [String!] = null) {\n activeUser {\n projects(limit: 10, filter: { search: $search, onlyWithRoles: $onlyWithRoles }) {\n totalCount\n items {\n ...FormSelectProjects_Project\n }\n }\n }\n }\n": types.SearchProjectsDocument,
"\n query SearchProjectModels($search: String, $projectId: String!) {\n project(id: $projectId) {\n id\n models(limit: 10, filter: { search: $search }) {\n totalCount\n items {\n ...FormSelectModels_Model\n }\n }\n }\n }\n": types.SearchProjectModelsDocument,
+ "\n query ActiveUserGendoLimits {\n activeUser {\n id\n gendoAICredits {\n used\n limit\n resetDate\n }\n }\n }\n": types.ActiveUserGendoLimitsDocument,
"\n mutation requestGendoAIRender($input: GendoAIRenderInput!) {\n versionMutations {\n requestGendoAIRender(input: $input)\n }\n }\n": types.RequestGendoAiRenderDocument,
"\n query GendoAIRender(\n $gendoAiRenderId: String!\n $versionId: String!\n $projectId: String!\n ) {\n project(id: $projectId) {\n id\n version(id: $versionId) {\n id\n gendoAIRender(id: $gendoAiRenderId) {\n id\n projectId\n modelId\n versionId\n createdAt\n updatedAt\n gendoGenerationId\n status\n prompt\n camera\n responseImage\n user {\n name\n avatar\n id\n }\n }\n }\n }\n }\n": types.GendoAiRenderDocument,
"\n query GendoAIRenders($versionId: String!, $projectId: String!) {\n project(id: $projectId) {\n id\n version(id: $versionId) {\n id\n gendoAIRenders {\n totalCount\n items {\n id\n createdAt\n updatedAt\n status\n gendoGenerationId\n prompt\n camera\n }\n }\n }\n }\n }\n": types.GendoAiRendersDocument,
@@ -1059,6 +1060,10 @@ export function graphql(source: "\n query SearchProjects($search: String, $only
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query SearchProjectModels($search: String, $projectId: String!) {\n project(id: $projectId) {\n id\n models(limit: 10, filter: { search: $search }) {\n totalCount\n items {\n ...FormSelectModels_Model\n }\n }\n }\n }\n"): (typeof documents)["\n query SearchProjectModels($search: String, $projectId: String!) {\n project(id: $projectId) {\n id\n models(limit: 10, filter: { search: $search }) {\n totalCount\n items {\n ...FormSelectModels_Model\n }\n }\n }\n }\n"];
+/**
+ * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
+ */
+export function graphql(source: "\n query ActiveUserGendoLimits {\n activeUser {\n id\n gendoAICredits {\n used\n limit\n resetDate\n }\n }\n }\n"): (typeof documents)["\n query ActiveUserGendoLimits {\n activeUser {\n id\n gendoAICredits {\n used\n limit\n resetDate\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
diff --git a/packages/frontend-2/lib/common/generated/gql/graphql.ts b/packages/frontend-2/lib/common/generated/gql/graphql.ts
index 4d421cf8d..5a632111b 100644
--- a/packages/frontend-2/lib/common/generated/gql/graphql.ts
+++ b/packages/frontend-2/lib/common/generated/gql/graphql.ts
@@ -3555,6 +3555,7 @@ export type User = {
* @deprecated Part of the old API surface and will be removed in the future.
*/
favoriteStreams: StreamCollection;
+ gendoAICredits: UserGendoAiCredits;
/** Whether the user has a pending/active email verification token */
hasPendingVerification?: Maybe
;
id: Scalars['ID']['output'];
@@ -3743,6 +3744,13 @@ export type UserEmailMutationsSetPrimaryArgs = {
input: SetPrimaryUserEmailInput;
};
+export type UserGendoAiCredits = {
+ __typename?: 'UserGendoAICredits';
+ limit: Scalars['Int']['output'];
+ resetDate: Scalars['DateTime']['output'];
+ used: Scalars['Int']['output'];
+};
+
export type UserProjectsFilter = {
/** Only include projects where user has the specified roles */
onlyWithRoles?: InputMaybe>;
@@ -4949,6 +4957,11 @@ export type SearchProjectModelsQueryVariables = Exact<{
export type SearchProjectModelsQuery = { __typename?: 'Query', project: { __typename?: 'Project', id: string, models: { __typename?: 'ModelCollection', totalCount: number, items: Array<{ __typename?: 'Model', id: string, name: string }> } } };
+export type ActiveUserGendoLimitsQueryVariables = Exact<{ [key: string]: never; }>;
+
+
+export type ActiveUserGendoLimitsQuery = { __typename?: 'Query', activeUser?: { __typename?: 'User', id: string, gendoAICredits: { __typename?: 'UserGendoAICredits', used: number, limit: number, resetDate: string } } | null };
+
export type RequestGendoAiRenderMutationVariables = Exact<{
input: GendoAiRenderInput;
}>;
@@ -6257,6 +6270,7 @@ export const DeveloperSettingsApplicationsDocument = {"kind":"Document","definit
export const DeveloperSettingsAuthorizedAppsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"DeveloperSettingsAuthorizedApps"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"authorizedApps"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}}]}}]}}]}}]}}]} as unknown as DocumentNode;
export const SearchProjectsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"SearchProjects"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"search"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"onlyWithRoles"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},"defaultValue":{"kind":"NullValue"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"projects"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"10"}},{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"search"},"value":{"kind":"Variable","name":{"kind":"Name","value":"search"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"onlyWithRoles"},"value":{"kind":"Variable","name":{"kind":"Name","value":"onlyWithRoles"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"FormSelectProjects_Project"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"FormSelectProjects_Project"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Project"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]} as unknown as DocumentNode;
export const SearchProjectModelsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"SearchProjectModels"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"search"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"project"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"models"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"10"}},{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"search"},"value":{"kind":"Variable","name":{"kind":"Name","value":"search"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"FormSelectModels_Model"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"FormSelectModels_Model"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Model"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]} as unknown as DocumentNode;
+export const ActiveUserGendoLimitsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ActiveUserGendoLimits"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"gendoAICredits"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"used"}},{"kind":"Field","name":{"kind":"Name","value":"limit"}},{"kind":"Field","name":{"kind":"Name","value":"resetDate"}}]}}]}}]}}]} as unknown as DocumentNode;
export const RequestGendoAiRenderDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"requestGendoAIRender"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"GendoAIRenderInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"versionMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"requestGendoAIRender"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]}}]} as unknown as DocumentNode;
export const GendoAiRenderDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GendoAIRender"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"gendoAiRenderId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"versionId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"project"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"version"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"versionId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"gendoAIRender"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"gendoAiRenderId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"projectId"}},{"kind":"Field","name":{"kind":"Name","value":"modelId"}},{"kind":"Field","name":{"kind":"Name","value":"versionId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"gendoGenerationId"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"prompt"}},{"kind":"Field","name":{"kind":"Name","value":"camera"}},{"kind":"Field","name":{"kind":"Name","value":"responseImage"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode;
export const GendoAiRendersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GendoAIRenders"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"versionId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"project"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"version"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"versionId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"gendoAIRenders"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"gendoGenerationId"}},{"kind":"Field","name":{"kind":"Name","value":"prompt"}},{"kind":"Field","name":{"kind":"Name","value":"camera"}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode;
@@ -6512,6 +6526,7 @@ export type AllObjectTypes = {
UserAutomateInfo: UserAutomateInfo,
UserEmail: UserEmail,
UserEmailMutations: UserEmailMutations,
+ UserGendoAICredits: UserGendoAiCredits,
UserProjectsUpdatedMessage: UserProjectsUpdatedMessage,
UserSearchResultCollection: UserSearchResultCollection,
Version: Version,
@@ -7488,6 +7503,7 @@ export type UserFieldArgs = {
emails: {},
expiredSsoSessions: {},
favoriteStreams: UserFavoriteStreamsArgs,
+ gendoAICredits: {},
hasPendingVerification: {},
id: {},
isOnboardingFinished: {},
@@ -7523,6 +7539,11 @@ export type UserEmailMutationsFieldArgs = {
requestNewEmailVerification: UserEmailMutationsRequestNewEmailVerificationArgs,
setPrimary: UserEmailMutationsSetPrimaryArgs,
}
+export type UserGendoAiCreditsFieldArgs = {
+ limit: {},
+ resetDate: {},
+ used: {},
+}
export type UserProjectsUpdatedMessageFieldArgs = {
id: {},
project: {},
@@ -7829,6 +7850,7 @@ export type AllObjectFieldArgTypes = {
UserAutomateInfo: UserAutomateInfoFieldArgs,
UserEmail: UserEmailFieldArgs,
UserEmailMutations: UserEmailMutationsFieldArgs,
+ UserGendoAICredits: UserGendoAiCreditsFieldArgs,
UserProjectsUpdatedMessage: UserProjectsUpdatedMessageFieldArgs,
UserSearchResultCollection: UserSearchResultCollectionFieldArgs,
Version: VersionFieldArgs,
diff --git a/packages/frontend-2/lib/gendo/graphql/queriesAndMutations.ts b/packages/frontend-2/lib/gendo/graphql/queriesAndMutations.ts
index f72e17868..8c4293548 100644
--- a/packages/frontend-2/lib/gendo/graphql/queriesAndMutations.ts
+++ b/packages/frontend-2/lib/gendo/graphql/queriesAndMutations.ts
@@ -2,6 +2,19 @@ import { graphql } from '~~/lib/common/generated/gql'
// TODO: Clean up these operations and make them component fragment based. Also some of the props requested don't seem to even be used
+export const activeUserGendoLimits = graphql(`
+ query ActiveUserGendoLimits {
+ activeUser {
+ id
+ gendoAICredits {
+ used
+ limit
+ resetDate
+ }
+ }
+ }
+`)
+
export const requestGendoAIRender = graphql(`
mutation requestGendoAIRender($input: GendoAIRenderInput!) {
versionMutations {
diff --git a/packages/server/app.ts b/packages/server/app.ts
index e5a7592b6..52417d7cf 100644
--- a/packages/server/app.ts
+++ b/packages/server/app.ts
@@ -378,9 +378,9 @@ export async function init() {
app.use(corsMiddleware())
// there are some paths, that need the raw body
app.use((req, res, next) => {
- const rawPaths = ['/api/v1/billing/webhooks']
- if (rawPaths.includes(req.path)) {
- express.raw({ type: 'application/json' })(req, res, next)
+ const rawPaths = ['/api/v1/billing/webhooks', '/api/thirdparty/gendo/']
+ if (rawPaths.some((p) => req.path.startsWith(p))) {
+ express.raw({ type: 'application/json', limit: '100mb' })(req, res, next)
} else {
express.json({ limit: '100mb' })(req, res, next)
}
diff --git a/packages/server/assets/gendo/typedefs/gendo.graphql b/packages/server/assets/gendo/typedefs/gendo.graphql
index a5a868cc1..28a0e0df5 100644
--- a/packages/server/assets/gendo/typedefs/gendo.graphql
+++ b/packages/server/assets/gendo/typedefs/gendo.graphql
@@ -50,3 +50,13 @@ extend type Subscription {
projectVersionGendoAIRenderCreated(id: String!, versionId: String!): GendoAIRender!
projectVersionGendoAIRenderUpdated(id: String!, versionId: String!): GendoAIRender!
}
+
+type UserGendoAICredits {
+ used: Int!
+ limit: Int!
+ resetDate: DateTime!
+}
+
+extend type User {
+ gendoAICredits: UserGendoAICredits! @isOwner
+}
diff --git a/packages/server/modules/blobstorage/domain/operations.ts b/packages/server/modules/blobstorage/domain/operations.ts
index 81a290f0e..71d043881 100644
--- a/packages/server/modules/blobstorage/domain/operations.ts
+++ b/packages/server/modules/blobstorage/domain/operations.ts
@@ -33,12 +33,6 @@ export type GetBlobMetadataCollection = (params: {
}) => Promise<{ blobs: BlobStorageItem[]; cursor: Nullable }>
export type UploadFileStream = (
- storeFileStream: (params: {
- objectKey: string
- fileStream: Readable | Buffer
- }) => Promise<{
- fileHash: string
- }>,
params1: {
streamId: string
userId: string | undefined
@@ -50,3 +44,10 @@ export type UploadFileStream = (
fileStream: Readable | Buffer
}
) => Promise<{ blobId: string; fileName: string; fileHash: string }>
+
+type FileStream = string | Blob | Readable | Uint8Array | Buffer
+
+export type StoreFileStream = (args: {
+ objectKey: string
+ fileStream: FileStream
+}) => Promise<{ fileHash: string }>
diff --git a/packages/server/modules/blobstorage/index.ts b/packages/server/modules/blobstorage/index.ts
index 72a31eb94..1d5f94742 100644
--- a/packages/server/modules/blobstorage/index.ts
+++ b/packages/server/modules/blobstorage/index.ts
@@ -136,6 +136,7 @@ export const init: SpeckleModule['init'] = async (app) => {
const getBlobMetadata = getBlobMetadataFactory({ db: projectDb })
const uploadFileStream = uploadFileStreamFactory({
+ storeFileStream,
upsertBlob: upsertBlobFactory({ db: projectDb }),
updateBlob
})
@@ -178,7 +179,6 @@ export const init: SpeckleModule['init'] = async (app) => {
req.log = req.log.child({ blobId })
uploadOperations[blobId] = uploadFileStream(
- storeFileStream,
{ streamId, userId: req.context.userId },
{ blobId, fileName, fileType, fileStream: file }
)
diff --git a/packages/server/modules/blobstorage/objectStorage.ts b/packages/server/modules/blobstorage/objectStorage.ts
index c838f11e5..8ae08dfe2 100644
--- a/packages/server/modules/blobstorage/objectStorage.ts
+++ b/packages/server/modules/blobstorage/objectStorage.ts
@@ -14,7 +14,7 @@ import {
S3ClientConfig,
ServiceOutputTypes
} from '@aws-sdk/client-s3'
-import { Upload, Options as UploadOptions } from '@aws-sdk/lib-storage'
+import { Upload } from '@aws-sdk/lib-storage'
import {
getS3AccessKey,
getS3SecretKey,
@@ -27,6 +27,7 @@ import { ensureError, Nullable } from '@speckle/shared'
import { get } from 'lodash'
import type { Command } from '@aws-sdk/smithy-client'
import type stream from 'stream'
+import { StoreFileStream } from '@/modules/blobstorage/domain/operations'
let s3Config: Nullable = null
@@ -92,13 +93,7 @@ export const getObjectAttributes = async ({ objectKey }: { objectKey: string })
return { fileSize: data.ContentLength || 0 }
}
-export const storeFileStream = async ({
- objectKey,
- fileStream
-}: {
- objectKey: string
- fileStream: UploadOptions['params']['Body']
-}) => {
+export const storeFileStream: StoreFileStream = async ({ objectKey, fileStream }) => {
const { client, Bucket } = getObjectStorage()
const parallelUploads3 = new Upload({
client,
@@ -111,10 +106,6 @@ export const storeFileStream = async ({
leavePartsOnError: false // optional manually handle dropped parts
})
- // parallelUploads3.on('httpUploadProgress', (progress) => {
- // logger.debug(progress)
- // })
-
const data = await parallelUploads3.done()
// the ETag is a hash of the object. Could be used to dedupe stuff...
diff --git a/packages/server/modules/blobstorage/services/management.ts b/packages/server/modules/blobstorage/services/management.ts
index 94ce4a9da..522fa4307 100644
--- a/packages/server/modules/blobstorage/services/management.ts
+++ b/packages/server/modules/blobstorage/services/management.ts
@@ -1,6 +1,7 @@
import {
DeleteBlob,
GetBlobMetadata,
+ StoreFileStream,
UpdateBlob,
UploadFileStream,
UpsertBlob
@@ -16,8 +17,12 @@ import { MaybeAsync } from '@speckle/shared'
export const getFileSizeLimit = () => getFileSizeLimitMB() * 1024 * 1024
export const uploadFileStreamFactory =
- (deps: { upsertBlob: UpsertBlob; updateBlob: UpdateBlob }): UploadFileStream =>
- async (storeFileStream, params1, params2) => {
+ (deps: {
+ upsertBlob: UpsertBlob
+ updateBlob: UpdateBlob
+ storeFileStream: StoreFileStream
+ }): UploadFileStream =>
+ async (params1, params2) => {
const { streamId, userId } = params1
const { blobId, fileName, fileType, fileStream } = params2
@@ -39,7 +44,7 @@ export const uploadFileStreamFactory =
// even might fire faster, than the db insert, causing missing asset data in the db
await deps.upsertBlob(dbFile)
- const { fileHash } = await storeFileStream({ objectKey, fileStream })
+ const { fileHash } = await deps.storeFileStream({ objectKey, fileStream })
// here we should also update the blob db record with the fileHash
await deps.updateBlob({ id: blobId, item: { fileHash } })
diff --git a/packages/server/modules/blobstorage/tests/blobstorage.spec.js b/packages/server/modules/blobstorage/tests/blobstorage.spec.js
index bb15ff5f7..cf6f86910 100644
--- a/packages/server/modules/blobstorage/tests/blobstorage.spec.js
+++ b/packages/server/modules/blobstorage/tests/blobstorage.spec.js
@@ -23,12 +23,14 @@ const { cursorFromRows, decodeCursor } = require('@/modules/blobstorage/helpers/
const { createTestStream } = require('@/test/speckle-helpers/streamHelper')
const cryptoRandomString = require('crypto-random-string')
const { createTestUser } = require('@/test/authHelper')
+const { storeFileStream } = require('@/modules/blobstorage/objectStorage')
const fakeFileStreamStore = (fakeHash) => async () => ({ fileHash: fakeHash })
const upsertBlob = upsertBlobFactory({ db })
const updateBlob = updateBlobFactory({ db })
const uploadFileStream = uploadFileStreamFactory({
upsertBlob,
- updateBlob
+ updateBlob,
+ storeFileStream
})
const getBlobMetadata = getBlobMetadataFactory({ db })
const getBlobMetadataCollection = getBlobMetadataCollectionFactory({ db })
@@ -66,7 +68,7 @@ describe('Blob storage @blobstorage', () => {
data.map(([caseName, streamData, blobData]) =>
it(`Should throw if ${caseName} id length is incorrect`, async () => {
try {
- await uploadFileStream(null, streamData, blobData)
+ await uploadFileStream(streamData, blobData)
} catch (err) {
if (!(err instanceof BadRequestError)) throw err
expect(err.message).to.equal(`The ${caseName} id has to be of length 10`)
@@ -81,8 +83,13 @@ describe('Blob storage @blobstorage', () => {
const userId = fakeIdGenerator()
const fileHash = fakeIdGenerator()
+ const uploadFileStream = uploadFileStreamFactory({
+ upsertBlob,
+ updateBlob,
+ storeFileStream: fakeFileStreamStore(fileHash)
+ })
+
const blobData = await uploadFileStream(
- fakeFileStreamStore(fileHash),
{ streamId, userId },
{ blobId, fileName, fileType: '.something', fileStream: null }
)
diff --git a/packages/server/modules/core/graph/generated/graphql.ts b/packages/server/modules/core/graph/generated/graphql.ts
index 3501f56a4..2829184aa 100644
--- a/packages/server/modules/core/graph/generated/graphql.ts
+++ b/packages/server/modules/core/graph/generated/graphql.ts
@@ -3577,6 +3577,7 @@ export type User = {
* @deprecated Part of the old API surface and will be removed in the future.
*/
favoriteStreams: StreamCollection;
+ gendoAICredits: UserGendoAiCredits;
/** Whether the user has a pending/active email verification token */
hasPendingVerification?: Maybe;
id: Scalars['ID']['output'];
@@ -3765,6 +3766,13 @@ export type UserEmailMutationsSetPrimaryArgs = {
input: SetPrimaryUserEmailInput;
};
+export type UserGendoAiCredits = {
+ __typename?: 'UserGendoAICredits';
+ limit: Scalars['Int']['output'];
+ resetDate: Scalars['DateTime']['output'];
+ used: Scalars['Int']['output'];
+};
+
export type UserProjectsFilter = {
/** Only include projects where user has the specified roles */
onlyWithRoles?: InputMaybe>;
@@ -4717,6 +4725,7 @@ export type ResolversTypes = {
UserDeleteInput: UserDeleteInput;
UserEmail: ResolverTypeWrapper;
UserEmailMutations: ResolverTypeWrapper;
+ UserGendoAICredits: ResolverTypeWrapper;
UserProjectsFilter: UserProjectsFilter;
UserProjectsUpdatedMessage: ResolverTypeWrapper & { project?: Maybe }>;
UserProjectsUpdatedMessageType: UserProjectsUpdatedMessageType;
@@ -4975,6 +4984,7 @@ export type ResolversParentTypes = {
UserDeleteInput: UserDeleteInput;
UserEmail: UserEmail;
UserEmailMutations: MutationsObjectGraphQLReturn;
+ UserGendoAICredits: UserGendoAiCredits;
UserProjectsFilter: UserProjectsFilter;
UserProjectsUpdatedMessage: Omit & { project?: Maybe };
UserRoleInput: UserRoleInput;
@@ -6243,6 +6253,7 @@ export type UserResolvers, ParentType, ContextType>;
expiredSsoSessions?: Resolver, ParentType, ContextType>;
favoriteStreams?: Resolver>;
+ gendoAICredits?: Resolver;
hasPendingVerification?: Resolver, ParentType, ContextType>;
id?: Resolver;
isOnboardingFinished?: Resolver, ParentType, ContextType>;
@@ -6286,6 +6297,13 @@ export type UserEmailMutationsResolvers;
};
+export type UserGendoAiCreditsResolvers = {
+ limit?: Resolver;
+ resetDate?: Resolver;
+ used?: Resolver;
+ __isTypeOf?: IsTypeOfResolverFn;
+};
+
export type UserProjectsUpdatedMessageResolvers = {
id?: Resolver;
project?: Resolver, ParentType, ContextType>;
@@ -6653,6 +6671,7 @@ export type Resolvers = {
UserAutomateInfo?: UserAutomateInfoResolvers;
UserEmail?: UserEmailResolvers;
UserEmailMutations?: UserEmailMutationsResolvers;
+ UserGendoAICredits?: UserGendoAiCreditsResolvers;
UserProjectsUpdatedMessage?: UserProjectsUpdatedMessageResolvers;
UserSearchResultCollection?: UserSearchResultCollectionResolvers;
Version?: VersionResolvers;
diff --git a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts
index 50413575e..c290c4192 100644
--- a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts
+++ b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts
@@ -3558,6 +3558,7 @@ export type User = {
* @deprecated Part of the old API surface and will be removed in the future.
*/
favoriteStreams: StreamCollection;
+ gendoAICredits: UserGendoAiCredits;
/** Whether the user has a pending/active email verification token */
hasPendingVerification?: Maybe;
id: Scalars['ID']['output'];
@@ -3746,6 +3747,13 @@ export type UserEmailMutationsSetPrimaryArgs = {
input: SetPrimaryUserEmailInput;
};
+export type UserGendoAiCredits = {
+ __typename?: 'UserGendoAICredits';
+ limit: Scalars['Int']['output'];
+ resetDate: Scalars['DateTime']['output'];
+ used: Scalars['Int']['output'];
+};
+
export type UserProjectsFilter = {
/** Only include projects where user has the specified roles */
onlyWithRoles?: InputMaybe>;
diff --git a/packages/server/modules/gatekeeper/migrations/20241120063859_cascade_delete_checkout_session.ts b/packages/server/modules/gatekeeper/migrations/20241120063859_cascade_delete_checkout_session.ts
new file mode 100644
index 000000000..a310bef8a
--- /dev/null
+++ b/packages/server/modules/gatekeeper/migrations/20241120063859_cascade_delete_checkout_session.ts
@@ -0,0 +1,32 @@
+import { Knex } from 'knex'
+
+export async function up(knex: Knex): Promise {
+ await knex.schema.alterTable('workspace_checkout_sessions', (table) => {
+ table.dropForeign(['workspaceId'])
+ table
+ .foreign('workspaceId')
+ .references('id')
+ .inTable('workspaces')
+ .onDelete('CASCADE')
+ })
+ await knex.schema.alterTable('workspace_subscriptions', (table) => {
+ table.dropForeign(['workspaceId'])
+ table
+ .foreign('workspaceId')
+ .references('id')
+ .inTable('workspaces')
+ .onDelete('CASCADE')
+ })
+}
+
+export async function down(knex: Knex): Promise {
+ await knex.schema.alterTable('workspace_checkout_sessions', (table) => {
+ table.dropForeign(['workspaceId'])
+ table.foreign('workspaceId').references('id').inTable('workspaces')
+ })
+
+ await knex.schema.alterTable('workspace_subscriptions', (table) => {
+ table.dropForeign(['workspaceId'])
+ table.foreign('workspaceId').references('id').inTable('workspaces')
+ })
+}
diff --git a/packages/server/modules/gendo/clients/gendo.ts b/packages/server/modules/gendo/clients/gendo.ts
new file mode 100644
index 000000000..af26558c5
--- /dev/null
+++ b/packages/server/modules/gendo/clients/gendo.ts
@@ -0,0 +1,59 @@
+import { RequestNewImageGeneration } from '@/modules/gendo/domain/operations'
+import { GendoRenderRequestError } from '@/modules/gendo/errors/main'
+
+/**
+ *
+{
+ "userId": "user1234",
+ "prompt": "a beautiful stone barn next to a lake, sunset, water",
+ "webhookUrl": "",
+ "depthMapBase64": ""
+}
+ */
+
+export const requestNewImageGenerationFactory =
+ ({
+ endpoint,
+ token,
+ serverOrigin
+ }: {
+ endpoint: string
+ token: string
+ serverOrigin: string
+ }): RequestNewImageGeneration =>
+ async (input) => {
+ const webhookUrl = `${serverOrigin}/api/thirdparty/gendo/${input.projectId}`
+
+ // TODO: Fn handles too many concerns, refactor (e.g. the client fetch call)
+ // TODO: Fire off request to gendo api & get generationId, create record in db. Note: use gendo api key from env
+ const depthMapBase64 = input.baseImage.replace(/^data:image\/\w+;base64,/, '')
+ const gendoRequestBody = {
+ userId: input.userId,
+ depthMapBase64,
+ prompt: input.prompt,
+ webhookUrl
+ }
+
+ const response = await fetch(endpoint, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'x-api-token': token
+ },
+ body: JSON.stringify(gendoRequestBody)
+ })
+
+ const status = response.status
+ if (status !== 201) {
+ const body = await response.json().catch((e) => ({ error: `${e}` }))
+ throw new GendoRenderRequestError('Failed to enqueue gendo render.', {
+ info: { body }
+ })
+ }
+
+ const gendoResponseBody = (await response.json()) as {
+ status: string
+ generationId: string
+ }
+ return gendoResponseBody
+ }
diff --git a/packages/server/modules/gendo/domain/operations.ts b/packages/server/modules/gendo/domain/operations.ts
index 3f96497b4..a5088342d 100644
--- a/packages/server/modules/gendo/domain/operations.ts
+++ b/packages/server/modules/gendo/domain/operations.ts
@@ -1,5 +1,5 @@
import { GendoAiRenderInput } from '@/modules/core/graph/generated/graphql'
-import { GendoAIRender } from '@/modules/gendo/domain/types'
+import { GendoAIRender, UserCredits } from '@/modules/gendo/domain/types'
import { NullableKeysToOptional, Optional } from '@speckle/shared'
import { SetOptional } from 'type-fest'
@@ -33,5 +33,16 @@ export type CreateRenderRequest = (
export type UpdateRenderRequest = (input: {
responseImage: string
+ status: string
gendoGenerationId: string
}) => Promise
+
+export type RequestNewImageGeneration = (args: {
+ userId: string
+ baseImage: string
+ projectId: string
+ prompt: string
+}) => Promise<{ status: string; generationId: string }>
+
+export type GetUserCredits = (args: { userId: string }) => Promise
+export type UpsertUserCredits = (args: { userCredits: UserCredits }) => Promise
diff --git a/packages/server/modules/gendo/domain/types.ts b/packages/server/modules/gendo/domain/types.ts
index 19f7b30a2..3b0f6c67c 100644
--- a/packages/server/modules/gendo/domain/types.ts
+++ b/packages/server/modules/gendo/domain/types.ts
@@ -1,3 +1,5 @@
import { GendoAIRenderRecord } from '@/modules/gendo/helpers/types'
export type GendoAIRender = GendoAIRenderRecord
+
+export type UserCredits = { userId: string; used: number; resetDate: Date }
diff --git a/packages/server/modules/gendo/errors/main.ts b/packages/server/modules/gendo/errors/main.ts
index c84e415d9..ba2ab820e 100644
--- a/packages/server/modules/gendo/errors/main.ts
+++ b/packages/server/modules/gendo/errors/main.ts
@@ -11,3 +11,10 @@ export class GendoRenderRequestNotFoundError extends BaseError {
static defaultMessage = 'Gendo render request not found'
static statusCode = 404
}
+
+export class InsufficientGendoRenderCreditsError extends BaseError {
+ static code = 'INSUFFICIENT_GENDO_RENDER_CREDITS'
+ static defaultMessage =
+ 'You do not have enough GendoAi credits left for the operation'
+ static statusCode = 402
+}
diff --git a/packages/server/modules/gendo/graph/resolvers/index.ts b/packages/server/modules/gendo/graph/resolvers/index.ts
index 8bf2c5a7c..9a8db6394 100644
--- a/packages/server/modules/gendo/graph/resolvers/index.ts
+++ b/packages/server/modules/gendo/graph/resolvers/index.ts
@@ -20,114 +20,168 @@ import {
import { storeFileStream } from '@/modules/blobstorage/objectStorage'
import {
getLatestVersionRenderRequestsFactory,
+ getUserCreditsFactory,
getVersionRenderRequestFactory,
- storeRenderFactory
+ storeRenderFactory,
+ upsertUserCreditsFactory
} from '@/modules/gendo/repositories'
+import { getProjectDbClient } from '@/modules/multiregion/dbSelector'
+import { requestNewImageGenerationFactory } from '@/modules/gendo/clients/gendo'
+import {
+ getUserGendoAiCreditsFactory,
+ useUserGendoAiCreditsFactory
+} from '@/modules/gendo/services/userCredits'
import { db } from '@/db/knex'
+import {
+ getGendoAiApiEndpoint,
+ getGendoAIKey,
+ getGendoAICreditLimit,
+ getServerOrigin,
+ getFeatureFlags
+} from '@/modules/shared/helpers/envHelper'
-const createRenderRequest = createRenderRequestFactory({
- uploadFileStream: uploadFileStreamFactory({
- upsertBlob: upsertBlobFactory({ db }),
- updateBlob: updateBlobFactory({ db })
- }),
- storeFileStream,
- storeRender: storeRenderFactory({ db }),
- publish,
- fetch
+const upsertUserCredits = upsertUserCreditsFactory({ db })
+const getUserGendoAiCredits = getUserGendoAiCreditsFactory({
+ getUserCredits: getUserCreditsFactory({ db }),
+ upsertUserCredits
})
-const getLatestVersionRenderRequests = getLatestVersionRenderRequestsFactory({ db })
-const getVersionRenderRequest = getVersionRenderRequestFactory({ db })
-export = {
- Version: {
- async gendoAIRenders(parent) {
- const items = await getLatestVersionRenderRequests({ versionId: parent.id })
- return {
- totalCount: items.length,
- items
- }
- },
- async gendoAIRender(parent, args) {
- const item = await getVersionRenderRequest({
- versionId: parent.id,
- id: args.id
- })
+const { FF_GENDOAI_MODULE_ENABLED } = getFeatureFlags()
- return item
- }
- },
- GendoAIRender: {
- async user(parent, __args, ctx) {
- return await ctx.loaders.users.getUser.load(parent.userId)
- }
- },
- VersionMutations: {
- async requestGendoAIRender(__parent, args, ctx) {
- await authorizeResolver(
- ctx.userId,
- args.input.projectId,
- Roles.Stream.Reviewer,
- ctx.resourceAccessRules
- )
-
- const rateLimitResult = await getRateLimitResult(
- 'GENDO_AI_RENDER_REQUEST',
- ctx.userId as string
- )
- if (isRateLimitBreached(rateLimitResult)) {
- throw new RateLimitError(rateLimitResult)
- }
-
- await createRenderRequest({
- ...args.input,
- userId: ctx.userId!
- })
-
- return true
- }
- },
- Subscription: {
- projectVersionGendoAIRenderCreated: {
- subscribe: filteredSubscribe(
- ProjectSubscriptions.ProjectVersionGendoAIRenderCreated,
- async (payload, args, ctx) => {
- if (
- args.id !== payload.projectVersionGendoAIRenderCreated.projectId ||
- args.versionId !== payload.projectVersionGendoAIRenderCreated.versionId
- )
- return false
+export = FF_GENDOAI_MODULE_ENABLED
+ ? ({
+ Version: {
+ async gendoAIRenders(parent) {
+ const projectDb = await getProjectDbClient({ projectId: parent.streamId })
+ const items = await getLatestVersionRenderRequestsFactory({ db: projectDb })({
+ versionId: parent.id
+ })
+ return {
+ totalCount: items.length,
+ items
+ }
+ },
+ async gendoAIRender(parent, args) {
+ const projectDb = await getProjectDbClient({ projectId: parent.streamId })
+ const item = await getVersionRenderRequestFactory({ db: projectDb })({
+ versionId: parent.id,
+ id: args.id
+ })
+ return item
+ }
+ },
+ GendoAIRender: {
+ async user(parent, __args, ctx) {
+ return await ctx.loaders.users.getUser.load(parent.userId)
+ }
+ },
+ VersionMutations: {
+ async requestGendoAIRender(__parent, args, ctx) {
await authorizeResolver(
ctx.userId,
- args.id,
+ args.input.projectId,
Roles.Stream.Reviewer,
ctx.resourceAccessRules
)
- return true
- }
- )
- },
- projectVersionGendoAIRenderUpdated: {
- subscribe: filteredSubscribe(
- ProjectSubscriptions.ProjectVersionGendoAIRenderUpdated,
- async (payload, args, ctx) => {
- if (
- args.id !== payload.projectVersionGendoAIRenderUpdated.projectId ||
- args.versionId !== payload.projectVersionGendoAIRenderUpdated.versionId
+ const rateLimitResult = await getRateLimitResult(
+ 'GENDO_AI_RENDER_REQUEST',
+ ctx.userId as string
)
- return false
+ if (isRateLimitBreached(rateLimitResult)) {
+ throw new RateLimitError(rateLimitResult)
+ }
- await authorizeResolver(
- ctx.userId,
- args.id,
- Roles.Stream.Reviewer,
- ctx.resourceAccessRules
- )
+ const userId = ctx.userId!
+
+ const projectDb = await getProjectDbClient({
+ projectId: args.input.projectId
+ })
+
+ await useUserGendoAiCreditsFactory({
+ getUserGendoAiCredits,
+ upsertUserCredits,
+ maxCredits: getGendoAICreditLimit()
+ })({ userId, credits: 1 })
+
+ const requestNewImageGeneration = requestNewImageGenerationFactory({
+ endpoint: getGendoAiApiEndpoint(),
+ serverOrigin: getServerOrigin(),
+ token: getGendoAIKey()
+ })
+
+ const createRenderRequest = createRenderRequestFactory({
+ uploadFileStream: uploadFileStreamFactory({
+ storeFileStream,
+ upsertBlob: upsertBlobFactory({ db: projectDb }),
+ updateBlob: updateBlobFactory({ db: projectDb })
+ }),
+ requestNewImageGeneration,
+ storeRender: storeRenderFactory({ db: projectDb }),
+ publish
+ })
+
+ await createRenderRequest({
+ ...args.input,
+ userId
+ })
return true
}
- )
- }
- }
-} as Resolvers
+ },
+ User: {
+ gendoAICredits: async (_parent, _args, ctx) => {
+ const userCredits = await getUserGendoAiCredits({ userId: ctx.userId! })
+ return {
+ limit: getGendoAICreditLimit(),
+ ...userCredits
+ }
+ }
+ },
+ Subscription: {
+ projectVersionGendoAIRenderCreated: {
+ subscribe: filteredSubscribe(
+ ProjectSubscriptions.ProjectVersionGendoAIRenderCreated,
+ async (payload, args, ctx) => {
+ if (
+ args.id !== payload.projectVersionGendoAIRenderCreated.projectId ||
+ args.versionId !== payload.projectVersionGendoAIRenderCreated.versionId
+ )
+ return false
+
+ await authorizeResolver(
+ ctx.userId,
+ args.id,
+ Roles.Stream.Reviewer,
+ ctx.resourceAccessRules
+ )
+
+ return true
+ }
+ )
+ },
+ projectVersionGendoAIRenderUpdated: {
+ subscribe: filteredSubscribe(
+ ProjectSubscriptions.ProjectVersionGendoAIRenderUpdated,
+ async (payload, args, ctx) => {
+ if (
+ args.id !== payload.projectVersionGendoAIRenderUpdated.projectId ||
+ args.versionId !== payload.projectVersionGendoAIRenderUpdated.versionId
+ )
+ return false
+
+ await authorizeResolver(
+ ctx.userId,
+ args.id,
+ Roles.Stream.Reviewer,
+ ctx.resourceAccessRules
+ )
+
+ return true
+ }
+ )
+ }
+ }
+ } as Resolvers)
+ : {}
diff --git a/packages/server/modules/gendo/migrations/20241120140402_gendo_credits.ts b/packages/server/modules/gendo/migrations/20241120140402_gendo_credits.ts
new file mode 100644
index 000000000..27cce7ff7
--- /dev/null
+++ b/packages/server/modules/gendo/migrations/20241120140402_gendo_credits.ts
@@ -0,0 +1,18 @@
+import { Knex } from 'knex'
+
+export async function up(knex: Knex): Promise {
+ await knex.schema.createTable('gendo_user_credits', (table) => {
+ table
+ .string('userId')
+ .primary()
+ .references('id')
+ .inTable('users')
+ .onDelete('cascade')
+ table.timestamp('resetDate', { precision: 3, useTz: true }).notNullable()
+ table.integer('used').notNullable()
+ })
+}
+
+export async function down(knex: Knex): Promise {
+ await knex.schema.dropTable('gendo_user_credits')
+}
diff --git a/packages/server/modules/gendo/repositories/index.ts b/packages/server/modules/gendo/repositories/index.ts
index c16f7adbc..1a1cee585 100644
--- a/packages/server/modules/gendo/repositories/index.ts
+++ b/packages/server/modules/gendo/repositories/index.ts
@@ -2,16 +2,20 @@ import { GendoAIRenders } from '@/modules/core/dbSchema'
import {
GetLatestVersionRenderRequests,
GetRenderByGenerationId,
+ GetUserCredits,
GetVersionRenderRequest,
StoreRender,
- UpdateRenderRecord
+ UpdateRenderRecord,
+ UpsertUserCredits
} from '@/modules/gendo/domain/operations'
+import { UserCredits } from '@/modules/gendo/domain/types'
import { GendoAIRenderRecord } from '@/modules/gendo/helpers/types'
import { Knex } from 'knex'
import { pick } from 'lodash'
const tables = {
- gendoAIRenders: (db: Knex) => db(GendoAIRenders.name)
+ gendoAIRenders: (db: Knex) => db(GendoAIRenders.name),
+ gendoUserCredits: (db: Knex) => db('gendo_user_credits')
}
export const storeRenderFactory =
@@ -68,3 +72,21 @@ export const getVersionRenderRequestFactory =
.first()
return record
}
+
+export const getUserCreditsFactory =
+ ({ db }: { db: Knex }): GetUserCredits =>
+ async ({ userId }) => {
+ const userCredits = await tables
+ .gendoUserCredits(db)
+ .select()
+ .where({ userId })
+ .first()
+
+ return userCredits || null
+ }
+
+export const upsertUserCreditsFactory =
+ ({ db }: { db: Knex }): UpsertUserCredits =>
+ async ({ userCredits }) => {
+ await tables.gendoUserCredits(db).insert(userCredits).onConflict('userId').merge()
+ }
diff --git a/packages/server/modules/gendo/rest/index.ts b/packages/server/modules/gendo/rest/index.ts
index 19a44a837..2e9c5611c 100644
--- a/packages/server/modules/gendo/rest/index.ts
+++ b/packages/server/modules/gendo/rest/index.ts
@@ -1,12 +1,10 @@
import { corsMiddleware } from '@/modules/core/configs/cors'
-import { getGendoAIResponseKey } from '@/modules/shared/helpers/envHelper'
import { updateRenderRequestFactory } from '@/modules/gendo/services'
import type express from 'express'
import {
getRenderByGenerationIdFactory,
updateRenderRecordFactory
} from '@/modules/gendo/repositories'
-import { db } from '@/db/knex'
import { uploadFileStreamFactory } from '@/modules/blobstorage/services/management'
import { storeFileStream } from '@/modules/blobstorage/objectStorage'
import {
@@ -14,34 +12,57 @@ import {
upsertBlobFactory
} from '@/modules/blobstorage/repositories'
import { publish } from '@/modules/shared/utils/subscriptions'
+import { getProjectDbClient } from '@/modules/multiregion/dbSelector'
+import { createHmac, timingSafeEqual } from 'node:crypto'
+import { getGendoAIKey } from '@/modules/shared/helpers/envHelper'
+//Validate payload
+// function validatePayload(req, res, next) {
+// if (req.get(sigHeaderName)) {
+// //Extract Signature header
+//
+// }
+
+// return next();
+// }
export default function (app: express.Express) {
- const responseToken = getGendoAIResponseKey()
- const updateRenderRequest = updateRenderRequestFactory({
- getRenderByGenerationId: getRenderByGenerationIdFactory({ db }),
- uploadFileStream: uploadFileStreamFactory({
- upsertBlob: upsertBlobFactory({ db }),
- updateBlob: updateBlobFactory({ db })
- }),
- storeFileStream,
- updateRenderRecord: updateRenderRecordFactory({ db }),
- publish
- })
+ // const responseToken = getGendoAIResponseKey()
// Gendo api calls hit these endpoints w/ the results
- app.options('/api/thirdparty/gendo', corsMiddleware())
- app.post('/api/thirdparty/gendo', corsMiddleware(), async (req, res) => {
- if (req.headers['x-gendo-authorization'] !== responseToken) {
- return res.status(401).send('Speckle says you are not authorized 😠')
- }
+ app.options('/api/thirdparty/gendo/:projectId', corsMiddleware())
+ app.post('/api/thirdparty/gendo/:projectId', corsMiddleware(), async (req, res) => {
+ const sig = Buffer.from(req.get('x-signature-sha256') || '', 'utf8')
- const responseImage = req.body.generated_image
- // const status = req.body.status
- const gendoGenerationId = req.body.generationId
+ // //Calculate HMAC
+ const hmac = createHmac('sha256', getGendoAIKey())
+ const digest = Buffer.from(hmac.update(req.body).digest('base64'), 'utf-8')
+
+ // //Compare HMACs
+ if (sig.length !== digest.length || !timingSafeEqual(digest, sig)) {
+ return res.status(401).send('Speckle says your webhook signature is not valid 😠')
+ }
+ const payload = JSON.parse(req.body)
+ const responseImage = payload.imageBase64
+ const status = payload.status
+ const gendoGenerationId = payload.generationId
+
+ const projectDb = await getProjectDbClient({ projectId: req.params.projectId })
+
+ const updateRenderRequest = updateRenderRequestFactory({
+ getRenderByGenerationId: getRenderByGenerationIdFactory({ db: projectDb }),
+ uploadFileStream: uploadFileStreamFactory({
+ storeFileStream,
+ upsertBlob: upsertBlobFactory({ db: projectDb }),
+ updateBlob: updateBlobFactory({ db: projectDb })
+ }),
+ updateRenderRecord: updateRenderRecordFactory({ db: projectDb }),
+ publish
+ })
await updateRenderRequest({
gendoGenerationId,
- responseImage
+ responseImage,
+ status
})
res.status(200).send('Speckle says thank you 💖')
diff --git a/packages/server/modules/gendo/services/index.ts b/packages/server/modules/gendo/services/index.ts
index 5f5fffa00..272662e73 100644
--- a/packages/server/modules/gendo/services/index.ts
+++ b/packages/server/modules/gendo/services/index.ts
@@ -3,69 +3,33 @@ import {
ProjectSubscriptions,
PublishSubscription
} from '@/modules/shared/utils/subscriptions'
-import { storeFileStream } from '@/modules/blobstorage/objectStorage'
import {
CreateRenderRequest,
GetRenderByGenerationId,
+ RequestNewImageGeneration,
StoreRender,
UpdateRenderRecord,
UpdateRenderRequest
} from '@/modules/gendo/domain/operations'
import { UploadFileStream } from '@/modules/blobstorage/domain/operations'
-import {
- getGendoAIAPIEndpoint,
- getGendoAIKey,
- getServerOrigin
-} from '@/modules/shared/helpers/envHelper'
-import {
- GendoRenderRequestError,
- GendoRenderRequestNotFoundError
-} from '@/modules/gendo/errors/main'
+import { GendoRenderRequestNotFoundError } from '@/modules/gendo/errors/main'
export const createRenderRequestFactory =
(deps: {
+ requestNewImageGeneration: RequestNewImageGeneration
uploadFileStream: UploadFileStream
- storeFileStream: typeof storeFileStream
storeRender: StoreRender
publish: PublishSubscription
- fetch: typeof fetch
}): CreateRenderRequest =>
async (input) => {
- const endpoint = getGendoAIAPIEndpoint()
- const bearer = getGendoAIKey() as string
- const webhookUrl = `${getServerOrigin()}/api/thirdparty/gendo`
-
- // TODO: Fn handles too many concerns, refactor (e.g. the client fetch call)
- // TODO: Fire off request to gendo api & get generationId, create record in db. Note: use gendo api key from env
- const gendoRequestBody = {
+ const imageRequest = await deps.requestNewImageGeneration({
userId: input.userId,
- depthMap: input.baseImage,
+ baseImage: input.baseImage,
prompt: input.prompt,
- webhookUrl
- }
-
- const response = await fetch(endpoint, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Authorization: `Bearer ${bearer}`
- },
- body: JSON.stringify(gendoRequestBody)
+ projectId: input.projectId
})
-
- const status = response.status
- if (status !== 200) {
- const body = await response.json().catch((e) => ({ error: `${e}` }))
- throw new GendoRenderRequestError('Failed to enqueue gendo render.', {
- info: { body }
- })
- }
-
- const gendoResponseBody = (await response.json()) as {
- status: string
- generationId: string
- }
+ // ----
const baseImageBuffer = Buffer.from(
input.baseImage.replace(/^data:image\/\w+;base64,/, ''),
'base64'
@@ -73,7 +37,6 @@ export const createRenderRequestFactory =
const blobId = crs({ length: 10 })
await deps.uploadFileStream(
- deps.storeFileStream,
{ streamId: input.projectId, userId: input.userId },
{
blobId,
@@ -87,8 +50,8 @@ export const createRenderRequestFactory =
const newRecord = await deps.storeRender({
...input,
- status: gendoResponseBody.status,
- gendoGenerationId: gendoResponseBody.generationId,
+ status: imageRequest.status,
+ gendoGenerationId: imageRequest.generationId,
id: crs({ length: 10 })
})
@@ -105,7 +68,6 @@ export const updateRenderRequestFactory =
(deps: {
getRenderByGenerationId: GetRenderByGenerationId
uploadFileStream: UploadFileStream
- storeFileStream: typeof storeFileStream
updateRenderRecord: UpdateRenderRecord
publish: PublishSubscription
}): UpdateRenderRequest =>
@@ -131,7 +93,6 @@ export const updateRenderRequestFactory =
const blobId = crs({ length: 10 })
await deps.uploadFileStream(
- deps.storeFileStream,
{ streamId: baseRequest.projectId, userId: baseRequest.userId },
{
blobId,
diff --git a/packages/server/modules/gendo/services/userCredits.ts b/packages/server/modules/gendo/services/userCredits.ts
new file mode 100644
index 000000000..42a044588
--- /dev/null
+++ b/packages/server/modules/gendo/services/userCredits.ts
@@ -0,0 +1,50 @@
+import { GetUserCredits, UpsertUserCredits } from '@/modules/gendo/domain/operations'
+import { UserCredits } from '@/modules/gendo/domain/types'
+import { InsufficientGendoRenderCreditsError } from '@/modules/gendo/errors/main'
+import dayjs from 'dayjs'
+
+type GetUserGendoAiCredits = (args: { userId: string }) => Promise
+
+export const getUserGendoAiCreditsFactory =
+ ({
+ getUserCredits,
+ upsertUserCredits
+ }: {
+ getUserCredits: GetUserCredits
+ upsertUserCredits: UpsertUserCredits
+ }) =>
+ async ({ userId }: { userId: string }) => {
+ //
+ const userCredits = await getUserCredits({ userId })
+ if (userCredits && userCredits.resetDate.getTime() > new Date().getTime())
+ return userCredits
+
+ const resetDate = dayjs(userCredits?.resetDate || new Date())
+ .add(1, 'month')
+ .toDate()
+
+ const newCredits = {
+ used: 0,
+ userId,
+ resetDate
+ }
+ await upsertUserCredits({ userCredits: newCredits })
+ return newCredits
+ }
+
+export const useUserGendoAiCreditsFactory =
+ ({
+ getUserGendoAiCredits,
+ upsertUserCredits,
+ maxCredits
+ }: {
+ getUserGendoAiCredits: GetUserGendoAiCredits
+ upsertUserCredits: UpsertUserCredits
+ maxCredits: number
+ }) =>
+ async ({ userId, credits }: { userId: string; credits: number }) => {
+ const userCredits = await getUserGendoAiCredits({ userId })
+ userCredits.used += credits
+ if (userCredits.used > maxCredits) throw new InsufficientGendoRenderCreditsError()
+ await upsertUserCredits({ userCredits })
+ }
diff --git a/packages/server/modules/shared/helpers/envHelper.ts b/packages/server/modules/shared/helpers/envHelper.ts
index 654e9ca04..5db03dcad 100644
--- a/packages/server/modules/shared/helpers/envHelper.ts
+++ b/packages/server/modules/shared/helpers/envHelper.ts
@@ -350,11 +350,11 @@ export function getGendoAIKey() {
return getStringFromEnv('GENDOAI_KEY')
}
-export function getGendoAIResponseKey() {
- return getStringFromEnv('GENDOAI_KEY_RESPONSE')
+export function getGendoAICreditLimit() {
+ return getIntFromEnv('GENDOAI_CREDIT_LIMIT')
}
-export function getGendoAIAPIEndpoint() {
+export function getGendoAiApiEndpoint() {
return getStringFromEnv('GENDOAI_API_ENDPOINT')
}
diff --git a/packages/server/test/graphql/generated/graphql.ts b/packages/server/test/graphql/generated/graphql.ts
index ad9035008..8b8f3f0d8 100644
--- a/packages/server/test/graphql/generated/graphql.ts
+++ b/packages/server/test/graphql/generated/graphql.ts
@@ -3559,6 +3559,7 @@ export type User = {
* @deprecated Part of the old API surface and will be removed in the future.
*/
favoriteStreams: StreamCollection;
+ gendoAICredits: UserGendoAiCredits;
/** Whether the user has a pending/active email verification token */
hasPendingVerification?: Maybe;
id: Scalars['ID']['output'];
@@ -3747,6 +3748,13 @@ export type UserEmailMutationsSetPrimaryArgs = {
input: SetPrimaryUserEmailInput;
};
+export type UserGendoAiCredits = {
+ __typename?: 'UserGendoAICredits';
+ limit: Scalars['Int']['output'];
+ resetDate: Scalars['DateTime']['output'];
+ used: Scalars['Int']['output'];
+};
+
export type UserProjectsFilter = {
/** Only include projects where user has the specified roles */
onlyWithRoles?: InputMaybe>;
diff --git a/utils/helm/speckle-server/templates/_helpers.tpl b/utils/helm/speckle-server/templates/_helpers.tpl
index 16b83b4c3..83c80b32e 100644
--- a/utils/helm/speckle-server/templates/_helpers.tpl
+++ b/utils/helm/speckle-server/templates/_helpers.tpl
@@ -731,15 +731,12 @@ Generate the environment variables for Speckle server and Speckle objects deploy
name: {{ default .Values.secretName .Values.server.gendoAI.key.secretName }}
key: {{ .Values.server.gendoAI.key.secretKey }}
-- name: GENDOAI_KEY_RESPONSE
- valueFrom:
- secretKeyRef:
- name: {{ default .Values.secretName .Values.server.gendoAI.keyResponse.secretName }}
- key: {{ .Values.server.gendoAI.keyResponse.secretKey }}
-
- name: GENDOAI_API_ENDPOINT
value: {{ .Values.server.gendoAI.apiUrl | quote }}
+- name: GENDOAI_CREDIT_LIMIT
+ value: {{ .Values.server.gendoAI.creditLimit | quote }}
+
- name: RATELIMIT_GENDO_AI_RENDER_REQUEST
value: {{ .Values.server.gendoai.ratelimiting.renderRequest | quote }}
diff --git a/utils/helm/speckle-server/values.schema.json b/utils/helm/speckle-server/values.schema.json
index 60590b3af..ed076530b 100644
--- a/utils/helm/speckle-server/values.schema.json
+++ b/utils/helm/speckle-server/values.schema.json
@@ -644,6 +644,11 @@
"description": "The url of the Gendo AI application, including protocol.",
"default": "https://api.gendo.ai/external/generate"
},
+ "creditLimit": {
+ "type": "number",
+ "description": "The number of Gendo AI credit a user gets every month.",
+ "default": 40
+ },
"key": {
"type": "object",
"properties": {
@@ -659,21 +664,6 @@
}
}
},
- "keyResponse": {
- "type": "object",
- "properties": {
- "secretName": {
- "type": "string",
- "description": "The name of the Kubernetes Secret containing the Gendo AI key response. If left blank, will default to the `secretName` parameter.",
- "default": ""
- },
- "secretKey": {
- "type": "string",
- "description": "The key within the Kubernetes Secret holding the Gendo AI key response as its value.",
- "default": "gendoai_key_response"
- }
- }
- },
"ratelimiting": {
"type": "object",
"properties": {
diff --git a/utils/helm/speckle-server/values.yaml b/utils/helm/speckle-server/values.yaml
index 4e97e8019..bb496e5e2 100644
--- a/utils/helm/speckle-server/values.yaml
+++ b/utils/helm/speckle-server/values.yaml
@@ -467,16 +467,14 @@ server:
gendoAI:
## @param server.gendoAI.apiUrl The url of the Gendo AI application, including protocol.
apiUrl: 'https://api.gendo.ai/external/generate'
+
+ ## @param server.gendoAI.creditLimit The number of Gendo AI credit a user gets every month.
+ creditLimit: 40
key:
## @param server.gendoAI.key.secretName The name of the Kubernetes Secret containing the Gendo AI key. If left blank, will default to the `secretName` parameter.
secretName: ''
## @param server.gendoAI.key.secretKey The key within the Kubernetes Secret holding the Gendo AI key as its value.
secretKey: 'gendoai_key'
- keyResponse:
- ## @param server.gendoAI.keyResponse.secretName The name of the Kubernetes Secret containing the Gendo AI key response. If left blank, will default to the `secretName` parameter.
- secretName: ''
- ## @param server.gendoAI.keyResponse.secretKey The key within the Kubernetes Secret holding the Gendo AI key response as its value.
- secretKey: 'gendoai_key_response'
ratelimiting:
## @param server.gendoAI.ratelimiting.renderRequest The number of render requests allowed per period
renderRequest: 1
diff --git a/workspace.code-workspace b/workspace.code-workspace
index 48902a487..998d7d280 100644
--- a/workspace.code-workspace
+++ b/workspace.code-workspace
@@ -95,6 +95,8 @@
"Bursty",
"discoverability",
"Encryptor",
+ "Gendo",
+ "GENDOAI",
"Insertable",
"mjml",
"OIDC",