diff --git a/apps/hash-ai-worker-ts/eslint.config.js b/apps/hash-ai-worker-ts/eslint.config.js index 0eac3025266..531ec875868 100644 --- a/apps/hash-ai-worker-ts/eslint.config.js +++ b/apps/hash-ai-worker-ts/eslint.config.js @@ -1,3 +1,15 @@ -import { createBase } from "@local/eslint/deprecated"; +import { createBase, defineConfig } from "@local/eslint/deprecated"; -export default createBase(import.meta.dirname); +export default [ + ...createBase(import.meta.dirname), + ...defineConfig([ + { + rules: { + /** + * @todo we should have separate browser/node configs + */ + "react-hooks/rules-of-hooks": "off", + }, + }, + ]), +]; diff --git a/apps/hash-ai-worker-ts/scripts/compare-llm-response.ts b/apps/hash-ai-worker-ts/scripts/compare-llm-response.ts index 1414a1dca19..78d0e9ae2e6 100644 --- a/apps/hash-ai-worker-ts/scripts/compare-llm-response.ts +++ b/apps/hash-ai-worker-ts/scripts/compare-llm-response.ts @@ -88,6 +88,7 @@ export const compareLlmResponses = async () => { const llmResponses = await Promise.all( models.map((model) => { return getLlmResponse( + // @ts-expect-error -- inference stumbling on Google AI model, @todo figure out why { ...llmParams, model, diff --git a/apps/hash-ai-worker-ts/scripts/compare-llm-response/types.ts b/apps/hash-ai-worker-ts/scripts/compare-llm-response/types.ts index f23be44a40c..202f8699819 100644 --- a/apps/hash-ai-worker-ts/scripts/compare-llm-response/types.ts +++ b/apps/hash-ai-worker-ts/scripts/compare-llm-response/types.ts @@ -2,12 +2,16 @@ import type { AccountId } from "@local/hash-graph-types/account"; import type { AnthropicLlmParams, + GoogleAiParams, LlmParams, OpenAiLlmParams, } from "../../src/activities/shared/get-llm-response/types.js"; export type CompareLlmResponseConfig = { models: LlmParams["model"][]; - llmParams: Omit & Omit; + llmParams: + | Omit + | Omit + | Omit; accountId?: AccountId; }; diff --git a/apps/hash-ai-worker-ts/src/activities/flow-activities/infer-metadata-from-document-action.ts b/apps/hash-ai-worker-ts/src/activities/flow-activities/infer-metadata-from-document-action.ts index 8d19c8f8a40..12bf6aff808 100644 --- a/apps/hash-ai-worker-ts/src/activities/flow-activities/infer-metadata-from-document-action.ts +++ b/apps/hash-ai-worker-ts/src/activities/flow-activities/infer-metadata-from-document-action.ts @@ -1,13 +1,3 @@ -import { createWriteStream } from "node:fs"; -import { mkdir, unlink } from "node:fs/promises"; -import path from "node:path"; -import { Readable } from "node:stream"; -import { finished } from "node:stream/promises"; -import type { ReadableStream } from "node:stream/web"; -import { fileURLToPath } from "node:url"; - -import { getAwsS3Config } from "@local/hash-backend-utils/aws-config"; -import { AwsS3StorageProvider } from "@local/hash-backend-utils/file-storage/aws-s3-storage-provider"; import type { OriginProvenance, PropertyProvenance, @@ -22,14 +12,13 @@ import { type OutputNameForAction, } from "@local/hash-isomorphic-utils/flows/action-definitions"; import type { PersistedEntity } from "@local/hash-isomorphic-utils/flows/types"; -import { generateUuid } from "@local/hash-isomorphic-utils/generate-uuid"; import { blockProtocolPropertyTypes, systemPropertyTypes, } from "@local/hash-isomorphic-utils/ontology-type-ids"; import type { + DocProperties, File, - TitlePropertyValue, } from "@local/hash-isomorphic-utils/system-types/shared"; import { extractEntityUuidFromEntityId } from "@local/hash-subgraph"; import { StatusCode } from "@local/status"; @@ -43,15 +32,15 @@ import { getEntityByFilter } from "../shared/get-entity-by-filter.js"; import { getFlowContext } from "../shared/get-flow-context.js"; import { graphApiClient } from "../shared/graph-api-client.js"; import { logProgress } from "../shared/log-progress.js"; +import { useFileSystemPathFromEntity } from "../shared/use-file-system-file-from-url.js"; import { generateDocumentPropertyPatches } from "./infer-metadata-from-document-action/generate-property-patches.js"; import { generateDocumentProposedEntitiesAndCreateClaims } from "./infer-metadata-from-document-action/generate-proposed-entities-and-claims.js"; import { getLlmAnalysisOfDoc } from "./infer-metadata-from-document-action/get-llm-analysis-of-doc.js"; import type { FlowActionActivity } from "./types.js"; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -const baseFilePath = path.join(__dirname, "/var/tmp_files"); +const isFileEntity = (entity: Entity): entity is Entity => + systemPropertyTypes.fileStorageKey.propertyTypeBaseUrl in entity.properties && + blockProtocolPropertyTypes.fileUrl.propertyTypeBaseUrl in entity.properties; export const inferMetadataFromDocumentAction: FlowActionActivity = async ({ inputs, @@ -109,118 +98,62 @@ export const inferMetadataFromDocumentAction: FlowActionActivity = async ({ }; } - const fileUrl = - documentEntity.properties[ - blockProtocolPropertyTypes.fileUrl.propertyTypeBaseUrl - ]; - - if (!fileUrl) { - return { - code: StatusCode.InvalidArgument, - contents: [], - message: `Document entity with entityId ${documentEntityId} does not have a fileUrl property`, - }; - } - - if (typeof fileUrl !== "string") { + if (!isFileEntity(documentEntity)) { return { code: StatusCode.InvalidArgument, contents: [], - message: `Document entity with entityId ${documentEntityId} has a fileUrl property of type '${typeof fileUrl}', expected 'string'`, + message: `Document entity with entityId ${documentEntityId} is not a file entity`, }; } - const storageKey = + const fileUrl = documentEntity.properties[ - systemPropertyTypes.fileStorageKey.propertyTypeBaseUrl + "https://blockprotocol.org/@blockprotocol/types/property-type/file-url/" ]; - if (!storageKey) { - return { - code: StatusCode.InvalidArgument, - contents: [], - message: `Document entity with entityId ${documentEntityId} does not have a fileStorageKey property`, - }; - } - - if (typeof storageKey !== "string") { - return { - code: StatusCode.InvalidArgument, - contents: [], - message: `Document entity with entityId ${documentEntityId} has a fileStorageKey property of type '${typeof storageKey}', expected 'string'`, - }; - } - - await mkdir(baseFilePath, { recursive: true }); - - const filePath = `${baseFilePath}/${generateUuid()}.pdf`; - - const s3Config = getAwsS3Config(); - - const downloadProvider = new AwsS3StorageProvider(s3Config); - - const urlForDownload = await downloadProvider.presignDownload({ - entity: documentEntity as Entity, - expiresInSeconds: 60 * 60, - key: storageKey, - }); - - const fetchFileResponse = await fetch(urlForDownload); - - if (!fetchFileResponse.ok || !fetchFileResponse.body) { + if (!fileUrl) { return { code: StatusCode.NotFound, contents: [], - message: `Document entity with entityId ${documentEntityId} has a fileUrl ${fileUrl} that could not be fetched: ${fetchFileResponse.statusText}`, - }; - } - - try { - const fileStream = createWriteStream(filePath); - await finished( - Readable.fromWeb( - fetchFileResponse.body as ReadableStream, - ).pipe(fileStream), - ); - } catch (error) { - await unlink(filePath); - return { - code: StatusCode.Internal, - contents: [], - message: `Failed to write file to file system: ${(error as Error).message}`, + message: `Document entity with entityId ${documentEntityId} does not have a fileUrl property`, }; } const pdfParser = new PDFParser(); - const documentJson = await new Promise((resolve, reject) => { - pdfParser.on("pdfParser_dataError", (errData) => - reject(errData.parserError), - ); - - pdfParser.on("pdfParser_dataReady", (pdfData) => { - resolve(pdfData); - }); - - // @todo: https://linear.app/hash/issue/H-3769/investigate-new-eslint-errors - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors - pdfParser.loadPDF(filePath).catch((err) => reject(err)); - }); - - const numberOfPages = documentJson.Pages.length; - - /** - * @todo H-3620: handle documents exceeding Vertex AI limit of 30MB - */ - - const documentMetadata = await getLlmAnalysisOfDoc({ - fileSystemPath: filePath, - hashFileStorageKey: storageKey, - entityId: documentEntityId, - fileUrl, - }); - - await unlink(filePath); + const { documentMetadata, numberOfPages } = await useFileSystemPathFromEntity( + documentEntity, + async ({ fileSystemPath }) => { + const documentJson = await new Promise((resolve, reject) => { + pdfParser.on("pdfParser_dataError", (errData) => + reject(errData.parserError), + ); + + pdfParser.on("pdfParser_dataReady", (pdfData) => { + resolve(pdfData); + }); + + // @todo: https://linear.app/hash/issue/H-3769/investigate-new-eslint-errors + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + pdfParser.loadPDF(fileSystemPath).catch((err) => reject(err)); + }); + + const numPages = documentJson.Pages.length; + + /** + * @todo H-3620: handle documents exceeding Vertex AI limit of 30MB + */ + + const metadata = await getLlmAnalysisOfDoc({ + fileEntity: documentEntity, + }); + + return { + documentMetadata: metadata, + numberOfPages: numPages, + }; + }, + ); const { authors, @@ -291,16 +224,20 @@ export const inferMetadataFromDocumentAction: FlowActionActivity = async ({ }, ]); - const title = updatedEntity.properties[ - systemPropertyTypes.title.propertyTypeBaseUrl - ] as TitlePropertyValue; + const title = + "https://hash.ai/@hash/types/property-type/title/" in + updatedEntity.properties + ? (updatedEntity.properties as DocProperties)[ + "https://hash.ai/@hash/types/property-type/title/" + ] + : undefined; const proposedEntities = await generateDocumentProposedEntitiesAndCreateClaims({ aiAssistantAccountId, documentEntityId, documentMetadata: { authors }, - documentTitle: title, + documentTitle: title ?? "[Untitled]", provenance, propertyProvenance, }); diff --git a/apps/hash-ai-worker-ts/src/activities/flow-activities/infer-metadata-from-document-action/get-llm-analysis-of-doc.ts b/apps/hash-ai-worker-ts/src/activities/flow-activities/infer-metadata-from-document-action/get-llm-analysis-of-doc.ts index 7d7c4c85853..7961aa75d84 100644 --- a/apps/hash-ai-worker-ts/src/activities/flow-activities/infer-metadata-from-document-action/get-llm-analysis-of-doc.ts +++ b/apps/hash-ai-worker-ts/src/activities/flow-activities/infer-metadata-from-document-action/get-llm-analysis-of-doc.ts @@ -1,11 +1,7 @@ -import { - mustHaveAtLeastOne, - type VersionedUrl, -} from "@blockprotocol/type-system"; -import { Storage } from "@google-cloud/storage"; -import type { Part, ResponseSchema } from "@google-cloud/vertexai"; -import { SchemaType, VertexAI } from "@google-cloud/vertexai"; +import { type VersionedUrl } from "@blockprotocol/type-system"; +import { SchemaType } from "@google-cloud/vertexai"; import type { PropertyProvenance } from "@local/hash-graph-client"; +import type { Entity } from "@local/hash-graph-sdk/entity"; import type { EntityId, PropertyObjectWithMetadata, @@ -19,168 +15,33 @@ import { import { systemEntityTypes } from "@local/hash-isomorphic-utils/ontology-type-ids"; import { stringifyPropertyValue } from "@local/hash-isomorphic-utils/stringify-property-value"; import { mapGraphApiSubgraphToSubgraph } from "@local/hash-isomorphic-utils/subgraph-mapping"; +import type { File } from "@local/hash-isomorphic-utils/system-types/shared"; import type { EntityTypeRootType } from "@local/hash-subgraph"; import { getEntityTypes } from "@local/hash-subgraph/stdlib"; import dedent from "dedent"; -import { logger } from "../../shared/activity-logger.js"; import { type DereferencedEntityType, type DereferencedEntityTypeWithSimplifiedKeys, - type DereferencedPropertyType, dereferenceEntityType, type MinimalPropertyObject, } from "../../shared/dereference-entity-type.js"; import { getFlowContext } from "../../shared/get-flow-context.js"; +import { getLlmResponse } from "../../shared/get-llm-response.js"; +import { + getToolCallsFromLlmAssistantMessage, + type LlmFileMessageContent, + type LlmMessageTextContent, + type LlmUserMessage, +} from "../../shared/get-llm-response/llm-message.js"; import { graphApiClient } from "../../shared/graph-api-client.js"; -/** - * The Vertex AI controlled generation (i.e. output) schema supports a subset of the Vertex AI schema fields, - * which are themselves a subset of OpenAPI schema fields. - * - * Any fields which are supported by Vertex AI but not by controlled generation will be IGNORED. - * Any fields which are not supported by Vertex AI will result in the request being REJECTED. - * - * We want to exclude the fields which will be REJECTED, i.e. are not supported at all by Vertex AI - * - * The fields which are IGNORED we allow through. Controlled generation may support them in future, - * in which case they will automatically start to be accounted for. - * - * The fields which are rejected we need to manually remove from this list when we discover that Vertex AI now supports them. - * - * There are some fields which are not listed here but instead handled specially in {@link transformSchemaForGoogle}, - * because we can rewrite them to constrain the output (e.g. 'const' can become an enum with a single option). - * - * @see https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/control-generated-output - */ -const vertexSchemaUnsupportedFields = ["$id", "multipleOf", "pattern"] as const; - -/** - * These are special fields we use in HASH but do not appear in any JSON Schema spec. - * They will never be supported in Vertex AI, so we must remove them. - */ -const nonStandardSchemaFields = [ - "abstract", - "titlePlural", - "kind", - "labelProperty", - "inverse", -]; - -const fieldsToExclude = [ - ...vertexSchemaUnsupportedFields, - ...nonStandardSchemaFields, -]; - -/** - * @see https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/control-generated-output#fields - */ -const vertexSupportedFormatValues = ["date", "date-time", "duration", "time"]; - -const transformSchemaForGoogle = (schema: unknown): unknown => { - if (typeof schema !== "object" || schema === null) { - return schema; - } - - if (Array.isArray(schema)) { - return schema.map(transformSchemaForGoogle); - } - - const result: Record = {}; - for (const [uncheckedKey, value] of Object.entries(schema)) { - const key = uncheckedKey === "oneOf" ? "anyOf" : uncheckedKey; - - if (key === "format" && typeof value === "string") { - if (vertexSupportedFormatValues.includes(value)) { - result[key] = value; - } else { - continue; - } - } - - if (key === "type" && typeof value === "string") { - if (value === "null") { - /** - * Google doesn't support type: "null", instead it supports nullable: boolean; - */ - throw new Error( - "type: 'null' is not supported. This should have been addressed by schema transformation.", - ); - } else { - // Google wants the type to be uppercase for some reason - result[key] = value.toUpperCase(); - } - } else if (typeof value === "object") { - if ( - "oneOf" in value && - (value as DereferencedPropertyType).oneOf.find((option) => { - if ("type" in option) { - return option.type === "null"; - } - return false; - }) - ) { - /** - * Google doesn't support type: "null", instead it supports nullable: boolean; - * We need to transform any schema containing oneOf to add nullable: true to all its options. - */ - const newOneOf = (value as DereferencedPropertyType).oneOf - .filter((option) => "type" in option && option.type !== "null") - .map((option: DereferencedPropertyType["oneOf"][number]) => ({ - ...option, - nullable: true, - })); - - if (newOneOf.length === 0) { - /** - * The Vertex AI schema requires that a schema has at least one type field, - * but does not support 'null' as a type (instead you must pass nullable: true). - * Therefore if someone happens to define a property type which ONLY accepts null, - * there is no way to represent this in the Vertex AI schema. - * - * If we define a type it will incorrect be constrainted to '{type} | null'. - */ - throw new Error( - "Property type must have at least one option which is not null", - ); - } - - (value as DereferencedPropertyType).oneOf = - mustHaveAtLeastOne(newOneOf); - } - - result[key] = transformSchemaForGoogle(value); - } else if (fieldsToExclude.includes(key)) { - if (typeof value === "object" && value !== null) { - /** - * If the value is an object, this might well be a property which happens to have the same simplified key - * as one of our rejected fields. - */ - if ("title" in value || "titlePlural" in value) { - /** - * This is the 'inverse' field, the only one of our excluded fields which has an object value. - */ - continue; - } - result[key] = transformSchemaForGoogle(value); - } - /** - * If the value is not an object, we have one of our fields which will be rejected, - * not a schema. - */ - continue; - } else { - result[key] = value; - } - } - return result; -}; - const generateOutputSchema = ( dereferencedDocEntityTypes: DereferencedEntityType[], -): ResponseSchema => { - const rawSchema = { - type: SchemaType.OBJECT, +) => { + return { + type: "object", + additionalProperties: false, properties: { documentMetadata: { anyOf: dereferencedDocEntityTypes.map( @@ -193,7 +54,7 @@ const generateOutputSchema = ( required, }) => { return { - type: SchemaType.OBJECT, + type: "object", title, properties: { entityTypeId: { type: SchemaType.STRING, enum: [$id] }, @@ -205,30 +66,28 @@ const generateOutputSchema = ( ), }, authors: { - type: SchemaType.ARRAY, + type: "array", items: { - type: SchemaType.OBJECT, + type: "object", + additionalProperties: false, properties: { affiliatedWith: { - type: SchemaType.ARRAY, + type: "array", items: { - type: SchemaType.STRING, + type: "string", }, description: "Any institution(s) or organization(s) that the document identifies the author as being affiliated with", }, name: { description: "The name of the author", - type: SchemaType.STRING, + type: "string", }, }, }, }, }, - }; - - // @ts-expect-error -- we could fix this by making {@link transformSchemaForGoogle} types more precise - return transformSchemaForGoogle(rawSchema); + } as const; }; type DocumentMetadataWithSimplifiedProperties = { @@ -512,64 +371,13 @@ const unsimplifyDocumentMetadata = ( }; }; -const googleCloudProjectId = process.env.GOOGLE_CLOUD_HASH_PROJECT_ID; - -let _vertexAi: VertexAI | undefined; - -const getVertexAi = () => { - if (!googleCloudProjectId) { - throw new Error( - "GOOGLE_CLOUD_HASH_PROJECT_ID environment variable is not set", - ); - } - - if (_vertexAi) { - return _vertexAi; - } - - const vertexAI = new VertexAI({ - project: googleCloudProjectId, - location: "us-east4", - }); - - _vertexAi = vertexAI; - - return vertexAI; -}; - -let _googleCloudStorage: Storage | undefined; - -const storageBucket = process.env.GOOGLE_CLOUD_STORAGE_BUCKET; - -const getGoogleCloudStorage = () => { - if (_googleCloudStorage) { - return _googleCloudStorage; - } - - const storage = new Storage(); - _googleCloudStorage = storage; - - return storage; -}; - export const getLlmAnalysisOfDoc = async ({ - hashFileStorageKey, - fileSystemPath, - entityId, - fileUrl, + fileEntity, }: { - hashFileStorageKey: string; - fileSystemPath: string; - entityId: EntityId; - fileUrl: string; + fileEntity: Entity; }): Promise => { - if (!storageBucket) { - throw new Error( - "GOOGLE_CLOUD_STORAGE_BUCKET environment variable is not set", - ); - } - - const { userAuthentication } = await getFlowContext(); + const { userAuthentication, flowEntityId, stepId, webId } = + await getFlowContext(); const docsEntityTypeSubgraph = await graphApiClient .getEntityTypeSubgraph(userAuthentication.actorId, { @@ -626,47 +434,8 @@ export const getLlmAnalysisOfDoc = async ({ dereferencedDocEntityTypes.map((type) => type.schema), ); - const vertexAi = getVertexAi(); - - const gemini = vertexAi.getGenerativeModel({ - model: "gemini-1.5-pro", - generationConfig: { - responseMimeType: "application/json", - responseSchema: schema, - }, - }); - - const storage = getGoogleCloudStorage(); - - const cloudStorageFilePath = `gs://${storageBucket}/${hashFileStorageKey}`; - - try { - await storage.bucket(storageBucket).file(hashFileStorageKey).getMetadata(); - - logger.info( - `Already exists in Google Cloud Storage: HASH key ${hashFileStorageKey} in ${storageBucket} bucket`, - ); - } catch (err) { - if ("code" in (err as Error) && (err as { code: unknown }).code === 404) { - await storage - .bucket(storageBucket) - .upload(fileSystemPath, { destination: hashFileStorageKey }); - logger.info( - `Uploaded to Google Cloud Storage: HASH key ${hashFileStorageKey} in ${storageBucket} bucket`, - ); - } else { - throw err; - } - } - - const filePart: Part = { - fileData: { - fileUri: cloudStorageFilePath, - mimeType: "application/pdf", - }, - }; - - const textPart = { + const textContent: LlmMessageTextContent = { + type: "text", text: dedent(`Please provide metadata about this document, using only the information visible in the document. You are given multiple options of what type of document this might be, and must choose from them. @@ -680,31 +449,75 @@ export const getLlmAnalysisOfDoc = async ({ If you're not confident about any of the metadata fields, omit them.`), }; - const request = { - contents: [{ role: "user", parts: [filePart, textPart] }], + const fileContent: LlmFileMessageContent = { + type: "file", + fileEntity: { + entityId: fileEntity.entityId, + properties: fileEntity.properties, + }, }; - /** - * @todo H-3922 add usage tracking for Gemini models - * - * Documents count as 258 tokens per page. - */ - const resp = await gemini.generateContent(request); + const message: LlmUserMessage = { + role: "user", + content: [textContent, fileContent], + }; - const contentResponse = resp.response.candidates?.[0]?.content.parts[0]?.text; + const response = await getLlmResponse( + { + model: "gemini-1.5-pro-002", + messages: [message], + toolChoice: "required", + tools: [ + { + name: "provideDocumentMetadata" as const, + description: "Provide metadata about the document", + inputSchema: schema, + }, + ], + }, + { + customMetadata: { + stepId, + taskName: "infer-metadata-from-document", + }, + userAccountId: userAuthentication.actorId, + graphApiClient, + incurredInEntities: [{ entityId: flowEntityId }], + webId, + }, + ); - if (!contentResponse) { - throw new Error("No content response from LLM analysis"); + if (response.status !== "ok") { + throw new Error( + `LLM analysis failed: ${ + response.status === "aborted" + ? "aborted" + : response.status === "api-error" + ? response.message + : response.status + }`, + ); } - const parsedResponse: unknown = JSON.parse(contentResponse); + const toolCalls = getToolCallsFromLlmAssistantMessage({ + message: response.message, + }); + + const toolCall = toolCalls[0]; + + if (!toolCall) { + throw new Error("No tool call found"); + } return unsimplifyDocumentMetadata( - parsedResponse, + toolCall.input, dereferencedDocEntityTypes, { - entityId, - fileUrl, + entityId: fileEntity.entityId, + fileUrl: + fileEntity.properties[ + "https://blockprotocol.org/@blockprotocol/types/property-type/file-url/" + ], }, ); }; diff --git a/apps/hash-ai-worker-ts/src/activities/flow-activities/research-entities-action/shared/deduplicate-entities.ts b/apps/hash-ai-worker-ts/src/activities/flow-activities/research-entities-action/shared/deduplicate-entities.ts index 5eec37fbbac..2ec8aec80aa 100644 --- a/apps/hash-ai-worker-ts/src/activities/flow-activities/research-entities-action/shared/deduplicate-entities.ts +++ b/apps/hash-ai-worker-ts/src/activities/flow-activities/research-entities-action/shared/deduplicate-entities.ts @@ -5,7 +5,7 @@ import dedent from "dedent"; import { logger } from "../../../shared/activity-logger.js"; import { getFlowContext } from "../../../shared/get-flow-context.js"; import { getLlmResponse } from "../../../shared/get-llm-response.js"; -import type { AnthropicMessageModel } from "../../../shared/get-llm-response/anthropic-client.js"; +import type { PermittedAnthropicModel } from "../../../shared/get-llm-response/anthropic-client.js"; import { getToolCallsFromLlmAssistantMessage } from "../../../shared/get-llm-response/llm-message.js"; import type { LlmParams, @@ -101,7 +101,7 @@ const defaultModel: LlmParams["model"] = "claude-3-5-sonnet-20240620"; export const deduplicateEntities = async (params: { entities: (LocalEntitySummary | ExistingEntitySummary)[]; - model?: PermittedOpenAiModel | AnthropicMessageModel; + model?: PermittedOpenAiModel | PermittedAnthropicModel; exceededMaxTokensAttempt?: number | null; }): Promise< { duplicates: DuplicateReport[] } & { diff --git a/apps/hash-ai-worker-ts/src/activities/parse-text-from-file.ts b/apps/hash-ai-worker-ts/src/activities/parse-text-from-file.ts index 462177c65f9..06c0ca29c83 100644 --- a/apps/hash-ai-worker-ts/src/activities/parse-text-from-file.ts +++ b/apps/hash-ai-worker-ts/src/activities/parse-text-from-file.ts @@ -43,7 +43,9 @@ export const parseTextFromFile = async ( const { presignedFileDownloadUrl, webMachineActorId } = params; const fileEntity = new Entity(params.fileEntity); - const fileBuffer = await fetchFileFromUrl(presignedFileDownloadUrl); + const fileResponse = await fetchFileFromUrl(presignedFileDownloadUrl); + + const fileBuffer = Buffer.from(await fileResponse.arrayBuffer()); let textParsingFunction: TextParsingFunction | undefined; for (const entityTypeId of fileEntity.metadata.entityTypeIds) { diff --git a/apps/hash-ai-worker-ts/src/activities/shared/fetch-file-from-url.ts b/apps/hash-ai-worker-ts/src/activities/shared/fetch-file-from-url.ts index 7f398ba6bb7..40c4fc77b7a 100644 --- a/apps/hash-ai-worker-ts/src/activities/shared/fetch-file-from-url.ts +++ b/apps/hash-ai-worker-ts/src/activities/shared/fetch-file-from-url.ts @@ -1,6 +1,6 @@ import isDocker from "is-docker"; -export const fetchFileFromUrl = async (url: string): Promise => { +export const fetchFileFromUrl = async (url: string): Promise => { const urlObject = new URL(url); let rewrittenUrl: string | undefined = undefined; @@ -24,7 +24,5 @@ export const fetchFileFromUrl = async (url: string): Promise => { throw new Error(`Failed to download file: ${response.statusText}`); } - const arrayBuffer = await response.arrayBuffer(); - - return Buffer.from(arrayBuffer); + return response; }; diff --git a/apps/hash-ai-worker-ts/src/activities/shared/get-llm-response.ts b/apps/hash-ai-worker-ts/src/activities/shared/get-llm-response.ts index 85ec4f41b2c..c2a805fce98 100644 --- a/apps/hash-ai-worker-ts/src/activities/shared/get-llm-response.ts +++ b/apps/hash-ai-worker-ts/src/activities/shared/get-llm-response.ts @@ -19,6 +19,7 @@ import { logger } from "./activity-logger.js"; import { getFlowContext } from "./get-flow-context.js"; // import { checkWebServiceUsageNotExceeded } from "./get-llm-response/check-web-service-usage-not-exceeded.js"; import { getAnthropicResponse } from "./get-llm-response/get-anthropic-response.js"; +import { getGoogleAiResponse } from "./get-llm-response/get-google-ai-response.js"; import { getOpenAiResponse } from "./get-llm-response/get-openai-reponse.js"; import { logLlmRequest } from "./get-llm-response/log-llm-request.js"; import type { @@ -26,7 +27,10 @@ import type { LlmRequestMetadata, LlmResponse, } from "./get-llm-response/types.js"; -import { isLlmParamsAnthropicLlmParams } from "./get-llm-response/types.js"; +import { + isLlmParamsAnthropicLlmParams, + isLlmParamsGoogleAiParams, +} from "./get-llm-response/types.js"; import { stringify } from "./stringify.js"; export type UsageTrackingParams = { @@ -44,7 +48,7 @@ export type UsageTrackingParams = { }; /** - * This function sends a request to the Anthropic or OpenAI API based on the + * This function sends a request to the Anthropic, OpenAI or Google AI API based on the * `model` provided in the parameters. */ export const getLlmResponse = async ( @@ -90,7 +94,9 @@ export const getLlmResponse = async ( message: `Failed to retrieve AI assistant account ID ${userAccountId}`, provider: isLlmParamsAnthropicLlmParams(llmParams) ? "anthropic" - : "openai", + : isLlmParamsGoogleAiParams(llmParams) + ? "google-vertex-ai" + : "openai", }; } @@ -116,11 +122,13 @@ export const getLlmResponse = async ( stepId, }; - const llmResponse = ( - isLlmParamsAnthropicLlmParams(llmParams) - ? await getAnthropicResponse(llmParams, metadata) - : await getOpenAiResponse(llmParams, metadata) - ) as LlmResponse; + const { llmResponse, transformedRequest } = isLlmParamsAnthropicLlmParams( + llmParams, + ) + ? await getAnthropicResponse(llmParams, metadata) + : isLlmParamsGoogleAiParams(llmParams) + ? await getGoogleAiResponse(llmParams, metadata) + : await getOpenAiResponse(llmParams, metadata); const timeAfterApiCall = Date.now(); @@ -153,7 +161,9 @@ export const getLlmResponse = async ( customMetadata, serviceName: isLlmParamsAnthropicLlmParams(llmParams) ? "Anthropic" - : "OpenAI", + : isLlmParamsGoogleAiParams(llmParams) + ? "Google AI" + : "OpenAI", featureName: llmParams.model, inputUnitCount: usage.inputTokens, outputUnitCount: usage.outputTokens, @@ -269,7 +279,8 @@ export const getLlmResponse = async ( secondsTaken: numberOfSeconds, request: llmParams, response: llmResponse, + transformedRequest, }); - return llmResponse; + return llmResponse as LlmResponse; }; diff --git a/apps/hash-ai-worker-ts/src/activities/shared/get-llm-response/anthropic-client.ts b/apps/hash-ai-worker-ts/src/activities/shared/get-llm-response/anthropic-client.ts index 7ea62b72f1e..a2d2a569f61 100644 --- a/apps/hash-ai-worker-ts/src/activities/shared/get-llm-response/anthropic-client.ts +++ b/apps/hash-ai-worker-ts/src/activities/shared/get-llm-response/anthropic-client.ts @@ -16,22 +16,22 @@ export const anthropic = new Anthropic({ apiKey: anthropicApiKey, }); -const anthropicMessageModels = [ +const permittedAnthropicModels = [ "claude-3-5-sonnet-20240620", "claude-3-opus-20240229", "claude-3-haiku-20240307", ] satisfies MessageCreateParamsBase["model"][]; -export type AnthropicMessageModel = (typeof anthropicMessageModels)[number]; +export type PermittedAnthropicModel = (typeof permittedAnthropicModels)[number]; -export const isAnthropicMessageModel = ( +export const isPermittedAnthropicModel = ( model: string, -): model is AnthropicMessageModel => - anthropicMessageModels.includes(model as AnthropicMessageModel); +): model is PermittedAnthropicModel => + permittedAnthropicModels.includes(model as PermittedAnthropicModel); /** @see https://docs.anthropic.com/claude/docs/models-overview#model-comparison */ export const anthropicMessageModelToContextWindow: Record< - AnthropicMessageModel, + PermittedAnthropicModel, number > = { "claude-3-haiku-20240307": 200_000, @@ -41,7 +41,7 @@ export const anthropicMessageModelToContextWindow: Record< /** @see https://docs.anthropic.com/en/docs/about-claude/models#model-comparison */ export const anthropicMessageModelToMaxOutput: Record< - AnthropicMessageModel, + PermittedAnthropicModel, number > = { "claude-3-haiku-20240307": 4096, @@ -54,7 +54,7 @@ export type AnthropicMessagesCreateParams = { | { type: "tool"; name: string } | { type: "any" } | { type: "auto" }; - model: AnthropicMessageModel; + model: PermittedAnthropicModel; messages: MessageParam[]; } & Omit; @@ -64,12 +64,7 @@ export const isAnthropicContentToolUseBlock = ( content: AnthropicMessagesCreateResponseContent, ): content is ToolUseBlock => content.type === "tool_use"; -export type AnthropicMessagesCreateResponse = Omit< - Message, - "content" | "stop_reason" -> & { - stop_reason: Message["stop_reason"] | "tool_use"; - content: AnthropicMessagesCreateResponseContent[]; +export type AnthropicMessagesCreateResponse = Message & { provider: AnthropicApiProvider; }; @@ -98,7 +93,7 @@ type AnthropicBedrockModel = /** @see https://docs.anthropic.com/en/api/claude-on-amazon-bedrock#api-model-names */ export const anthropicModelToBedrockModel: Record< - AnthropicMessageModel, + PermittedAnthropicModel, AnthropicBedrockModel > = { "claude-3-haiku-20240307": "anthropic.claude-3-haiku-20240307-v1:0", diff --git a/apps/hash-ai-worker-ts/src/activities/shared/get-llm-response/get-anthropic-response.ts b/apps/hash-ai-worker-ts/src/activities/shared/get-llm-response/get-anthropic-response.ts index 69764b5180d..30ccbb0e7e5 100644 --- a/apps/hash-ai-worker-ts/src/activities/shared/get-llm-response/get-anthropic-response.ts +++ b/apps/hash-ai-worker-ts/src/activities/shared/get-llm-response/get-anthropic-response.ts @@ -306,7 +306,10 @@ const createAnthropicMessagesWithToolsWithBackoff = async (params: { export const getAnthropicResponse = async ( params: AnthropicLlmParams, metadata: LlmRequestMetadata, -): Promise> => { +): Promise<{ + llmResponse: LlmResponse; + transformedRequest?: undefined; +}> => { const { tools, messages, @@ -361,10 +364,27 @@ export const getAnthropicResponse = async ( `Anthropic API error for request ${metadata.requestId}: ${stringifyError(error)}`, ); + if (isActivityCancelled()) { + return { + llmResponse: { + status: "aborted", + provider: initialProvider, + }, + }; + } + + const message = + "message" in (error as Error) + ? (error as Error).message + : "Unknown error"; + return { - status: isActivityCancelled() ? "aborted" : "api-error", - provider: initialProvider, - error, + llmResponse: { + status: "api-error", + provider: initialProvider, + message, + error, + }, }; } @@ -393,13 +413,15 @@ export const getAnthropicResponse = async ( if (anthropicResponse.stop_reason === "max_tokens") { return { - status: "max-tokens", - provider: anthropicResponse.provider, - lastRequestTime, - totalRequestTime, - requestMaxTokens: maxTokens, - response: anthropicResponse, - usage, + llmResponse: { + status: "max-tokens", + provider: anthropicResponse.provider, + lastRequestTime, + totalRequestTime, + requestMaxTokens: maxTokens, + response: anthropicResponse, + usage, + }, }; } @@ -410,15 +432,19 @@ export const getAnthropicResponse = async ( const retry = async (retryParams: { successfullyParsedToolCalls: ParsedLlmToolCall[]; retryMessageContent: LlmUserMessage["content"]; - }): Promise> => { + }): Promise<{ + llmResponse: LlmResponse; + }> => { if (retryCount > maxRetryCount) { return { - status: "exceeded-maximum-retries", - provider: anthropicResponse.provider, - invalidResponses: previousInvalidResponses ?? [], - lastRequestTime, - totalRequestTime, - usage, + llmResponse: { + status: "exceeded-maximum-retries", + provider: anthropicResponse.provider, + invalidResponses: previousInvalidResponses ?? [], + lastRequestTime, + totalRequestTime, + usage, + }, }; } @@ -469,7 +495,7 @@ export const getAnthropicResponse = async ( request: params, }); - return getAnthropicResponse( + return await getAnthropicResponse( { ...params, messages: [ @@ -582,17 +608,19 @@ export const getAnthropicResponse = async ( } return { - status: "ok", - id: anthropicResponse.id, - model: anthropicResponse.model, - provider: anthropicResponse.provider, - type: anthropicResponse.type, - stop_sequence: anthropicResponse.stop_sequence, - stopReason, - message, - usage, - invalidResponses: previousInvalidResponses ?? [], - lastRequestTime, - totalRequestTime, + llmResponse: { + status: "ok", + id: anthropicResponse.id, + model: anthropicResponse.model, + provider: anthropicResponse.provider, + type: anthropicResponse.type, + stop_sequence: anthropicResponse.stop_sequence, + stopReason, + message, + usage, + invalidResponses: previousInvalidResponses ?? [], + lastRequestTime, + totalRequestTime, + }, }; }; diff --git a/apps/hash-ai-worker-ts/src/activities/shared/get-llm-response/get-google-ai-response.ts b/apps/hash-ai-worker-ts/src/activities/shared/get-llm-response/get-google-ai-response.ts new file mode 100644 index 00000000000..f69c77eaf7d --- /dev/null +++ b/apps/hash-ai-worker-ts/src/activities/shared/get-llm-response/get-google-ai-response.ts @@ -0,0 +1,197 @@ +import { + type Content, + type FunctionDeclaration, + type GenerateContentResponse, + type Part, +} from "@google-cloud/vertexai"; +import type { Entity } from "@local/hash-graph-sdk/entity"; +import { stringifyError } from "@local/hash-isomorphic-utils/stringify-error"; +import type { File } from "@local/hash-isomorphic-utils/system-types/shared"; + +import { logger } from "../activity-logger.js"; +import { isActivityCancelled } from "../get-flow-context.js"; +import { mapGoogleMessagesToLlmMessages } from "./get-google-ai-response/map-google-messages-to-llm-messages.js"; +import { mapLlmContentToGooglePartAndUploadFiles } from "./get-google-ai-response/map-parts-and-upload-files.js"; +import { rewriteSchemaForGoogle } from "./get-google-ai-response/rewrite-schema-for-google.js"; +import { getVertexAiClient } from "./google-vertex-ai-client.js"; +import { type LlmMessage } from "./llm-message.js"; +import type { + GoogleAiParams, + LlmRequestMetadata, + LlmResponse, + LlmToolDefinition, + LlmUsage, +} from "./types.js"; + +const mapLlmToolDefinitionToGoogleAiToolDefinition = ( + tool: LlmToolDefinition, +): FunctionDeclaration => ({ + name: tool.name, + description: tool.description, + parameters: rewriteSchemaForGoogle(tool.inputSchema), +}); + +export const getGoogleAiResponse = async ( + params: GoogleAiParams, + _metadata: LlmRequestMetadata, +): Promise<{ + llmResponse: LlmResponse>; + transformedRequest: Record; +}> => { + const { + model, + tools, + systemPrompt, + messages, + previousInvalidResponses, + retryContext, + } = params; + + const vertexAi = getVertexAiClient(); + + const gemini = vertexAi.getGenerativeModel({ + model, + }); + + const timeBeforeRequest = Date.now(); + + const contents: Content[] = []; + const fileEntities: Pick, "entityId" | "properties">[] = []; + + for (const message of messages) { + const parts: Part[] = []; + + for (const llmContent of message.content) { + if (llmContent.type === "file") { + fileEntities.push(llmContent.fileEntity); + } + + parts.push(await mapLlmContentToGooglePartAndUploadFiles(llmContent)); + } + + contents.push({ + role: message.role, + parts, + }); + } + + let response: GenerateContentResponse; + const transformedRequest = { + contents, + systemInstruction: systemPrompt, + tools: tools + ? [ + { + functionDeclarations: tools.map( + mapLlmToolDefinitionToGoogleAiToolDefinition, + ), + }, + ] + : undefined, + } as const; + + try { + ({ response } = await gemini.generateContent(transformedRequest)); + } catch (error) { + logger.error(`Google AI API error: ${stringifyError(error)}`); + + if (isActivityCancelled()) { + return { + llmResponse: { + status: "aborted", + provider: "google-vertex-ai", + }, + transformedRequest, + }; + } + + const message = + "message" in (error as Error) + ? (error as Error).message + : "Unknown error"; + + return { + llmResponse: { + status: "api-error", + provider: "google-vertex-ai", + message, + error, + }, + transformedRequest, + }; + } + + const currentRequestTime = Date.now() - timeBeforeRequest; + + const { previousUsage } = retryContext ?? {}; + + const totalRequestTime = + previousInvalidResponses?.reduce( + (acc, { requestTime }) => acc + requestTime, + currentRequestTime, + ) ?? currentRequestTime; + + const responseContent = response.candidates?.[0]?.content; + + const { usageMetadata, promptFeedback } = response; + + if (!responseContent && !promptFeedback) { + throw new Error("No response content or prompt feedback"); + } + + const responseMessages: LlmMessage[] = responseContent + ? mapGoogleMessagesToLlmMessages({ + messages: [responseContent], + fileEntities, + }) + : [ + { + role: "assistant", + content: [ + { + type: "text", + text: promptFeedback?.blockReason + ? `Content blocked. Reason: ${promptFeedback.blockReasonMessage}.${promptFeedback.blockReasonMessage ? ` ${promptFeedback.blockReasonMessage}` : ""}` + : "Content blocked, no reason given.", + }, + ], + }, + ]; + + const message = responseMessages[0]; + + if (!message) { + throw new Error("No response message"); + } + + if (message.role === "user") { + throw new Error("Unexpected user message in response"); + } + + const usage: LlmUsage = { + inputTokens: + (previousUsage?.inputTokens ?? 0) + + (usageMetadata?.promptTokenCount ?? 0), + outputTokens: + (previousUsage?.outputTokens ?? 0) + + (usageMetadata?.candidatesTokenCount ?? 0), + totalTokens: + (previousUsage?.totalTokens ?? 0) + (usageMetadata?.totalTokenCount ?? 0), + }; + + const normalizedResponse: LlmResponse = { + invalidResponses: previousInvalidResponses ?? [], + status: "ok", + stopReason: promptFeedback?.blockReason ? "content_filter" : "stop", + provider: "google-vertex-ai", + usage, + lastRequestTime: currentRequestTime, + totalRequestTime, + message, + }; + + return { + llmResponse: normalizedResponse, + transformedRequest, + }; +}; diff --git a/apps/hash-ai-worker-ts/src/activities/shared/get-llm-response/get-google-ai-response/google-cloud-storage.ts b/apps/hash-ai-worker-ts/src/activities/shared/get-llm-response/get-google-ai-response/google-cloud-storage.ts new file mode 100644 index 00000000000..e1abf64a72f --- /dev/null +++ b/apps/hash-ai-worker-ts/src/activities/shared/get-llm-response/get-google-ai-response/google-cloud-storage.ts @@ -0,0 +1,132 @@ +import { Storage } from "@google-cloud/storage"; +import type { Entity } from "@local/hash-graph-sdk/entity"; +import type { File } from "@local/hash-isomorphic-utils/system-types/shared"; + +import { logger } from "../../activity-logger.js"; + +let _googleCloudStorage: Storage | undefined; + +const storageBucket = process.env.GOOGLE_CLOUD_STORAGE_BUCKET; + +const getGoogleCloudStorage = () => { + if (_googleCloudStorage) { + return _googleCloudStorage; + } + + const storage = new Storage(); + _googleCloudStorage = storage; + + return storage; +}; + +export const generateStoragePathFromHashFileStorageKey = ({ + hashFileStorageKey, +}: { + hashFileStorageKey: string; +}): string => { + if (!storageBucket) { + throw new Error( + "GOOGLE_CLOUD_STORAGE_BUCKET environment variable is not set", + ); + } + + return `gs://${storageBucket}/${hashFileStorageKey}`; +}; + +const getHashFileStorageKeyFromGcpStorageUri = ({ + gcpStorageUri, +}: { + gcpStorageUri: string; +}): string => { + const hashFileStorageKey = gcpStorageUri.split("/").pop(); + + if (!hashFileStorageKey) { + throw new Error( + `Hash file storage key not found in storage path ${gcpStorageUri}`, + ); + } + + return hashFileStorageKey; +}; + +export const getFileEntityFromGcpStorageUri = ({ + fileEntities, + gcpStorageUri, +}: { + fileEntities: Pick, "entityId" | "properties">[]; + gcpStorageUri: string; +}) => { + const hashFileStorageKey = getHashFileStorageKeyFromGcpStorageUri({ + gcpStorageUri, + }); + + const fileEntity = fileEntities.find( + (entity) => + entity.properties[ + "https://hash.ai/@hash/types/property-type/file-storage-key/" + ] === hashFileStorageKey, + ); + + if (!fileEntity) { + throw new Error( + `File entity not found for storage key ${hashFileStorageKey}`, + ); + } + + return fileEntity; +}; + +export const uploadFileToGcpStorage = async ({ + fileSystemPath, + fileEntity, +}: { + fileSystemPath: string; + fileEntity: Pick, "entityId" | "properties">; +}) => { + const storage = getGoogleCloudStorage(); + + if (!storageBucket) { + throw new Error( + "GOOGLE_CLOUD_STORAGE_BUCKET environment variable is not set", + ); + } + + const hashFileStorageKey = + fileEntity.properties[ + "https://hash.ai/@hash/types/property-type/file-storage-key/" + ]; + + if (!hashFileStorageKey) { + throw new Error( + `File entity ${fileEntity.entityId} has no file storage key`, + ); + } + + const cloudStorageFilePath = generateStoragePathFromHashFileStorageKey({ + hashFileStorageKey, + }); + + try { + await storage.bucket(storageBucket).file(hashFileStorageKey).getMetadata(); + + logger.info( + `Already exists in Google Cloud Storage: HASH key ${hashFileStorageKey} in ${storageBucket} bucket`, + ); + } catch (err) { + if ("code" in (err as Error) && (err as { code: unknown }).code === 404) { + await storage + .bucket(storageBucket) + .upload(fileSystemPath, { destination: hashFileStorageKey }); + + logger.info( + `Uploaded to Google Cloud Storage: HASH key ${hashFileStorageKey} in ${storageBucket} bucket`, + ); + } else { + throw err; + } + } + + return { + gcpStorageUri: cloudStorageFilePath, + }; +}; diff --git a/apps/hash-ai-worker-ts/src/activities/shared/get-llm-response/get-google-ai-response/map-google-messages-to-llm-messages.ts b/apps/hash-ai-worker-ts/src/activities/shared/get-llm-response/get-google-ai-response/map-google-messages-to-llm-messages.ts new file mode 100644 index 00000000000..dade14ba3f0 --- /dev/null +++ b/apps/hash-ai-worker-ts/src/activities/shared/get-llm-response/get-google-ai-response/map-google-messages-to-llm-messages.ts @@ -0,0 +1,104 @@ +import type { Content } from "@google-cloud/vertexai"; +import type { Entity } from "@local/hash-graph-sdk/entity"; +import type { File } from "@local/hash-isomorphic-utils/system-types/shared"; + +import type { LlmMessage } from "../llm-message.js"; +import { getFileEntityFromGcpStorageUri } from "./google-cloud-storage.js"; + +export const mapGoogleMessagesToLlmMessages = (params: { + messages: Content[]; + fileEntities: Pick, "entityId" | "properties">[]; +}): LlmMessage[] => { + const { messages, fileEntities } = params; + + return messages.map((message) => { + if (message.role === "user") { + return { + role: message.role, + content: message.parts.map((part) => { + if ("functionResponse" in part) { + if (!part.functionResponse) { + throw new Error("Function response is undefined"); + } + + return { + type: "tool_result" as const, + tool_use_id: part.functionResponse.name, + name: part.functionResponse.name, + content: JSON.stringify(part.functionResponse.response), + }; + } + + if ("text" in part) { + if (typeof part.text !== "string") { + throw new Error("Text is not a string"); + } + + return { + type: "text" as const, + text: part.text, + }; + } + + if ("fileData" in part) { + if (!part.fileData) { + throw new Error("File data is undefined"); + } + + const { entityId, properties } = getFileEntityFromGcpStorageUri({ + fileEntities, + gcpStorageUri: part.fileData.fileUri, + }); + + return { + type: "file" as const, + fileEntity: { + entityId, + properties, + }, + }; + } + + throw new Error( + `Unexpected content type for 'user' message: ${JSON.stringify(part)}`, + ); + }), + }; + } else if (message.role === "model") { + return { + role: "assistant", + content: message.parts.map((part) => { + if ("functionCall" in part) { + if (!part.functionCall) { + throw new Error("Function call is undefined"); + } + + return { + type: "tool_use" as const, + id: part.functionCall.name, + name: part.functionCall.name, + input: part.functionCall.args, + }; + } + + if ("text" in part) { + if (typeof part.text !== "string") { + throw new Error("Text is not a string"); + } + + return { + type: "text" as const, + text: part.text, + }; + } + + throw new Error( + `Unexpected content type for 'assistant' message: ${JSON.stringify(part)}`, + ); + }), + }; + } + + throw new Error(`Unexpected message role: ${message.role}`); + }); +}; diff --git a/apps/hash-ai-worker-ts/src/activities/shared/get-llm-response/get-google-ai-response/map-parts-and-upload-files.ts b/apps/hash-ai-worker-ts/src/activities/shared/get-llm-response/get-google-ai-response/map-parts-and-upload-files.ts new file mode 100644 index 00000000000..5a1606133b3 --- /dev/null +++ b/apps/hash-ai-worker-ts/src/activities/shared/get-llm-response/get-google-ai-response/map-parts-and-upload-files.ts @@ -0,0 +1,85 @@ +import type { JsonValue } from "@blockprotocol/type-system"; +import type { + FileDataPart, + FunctionCallPart, + FunctionResponsePart, + Part, + TextPart, +} from "@google-cloud/vertexai"; + +import { useFileSystemPathFromEntity } from "../../use-file-system-file-from-url.js"; +import type { LlmMessage } from "../llm-message.js"; +import { uploadFileToGcpStorage } from "./google-cloud-storage.js"; + +export const mapLlmContentToGooglePartAndUploadFiles = async ( + content: LlmMessage["content"][number], +): Promise => { + switch (content.type) { + case "file": { + const { fileEntity } = content; + + return await useFileSystemPathFromEntity( + fileEntity, + async ({ fileSystemPath }) => { + const { gcpStorageUri } = await uploadFileToGcpStorage({ + fileEntity, + fileSystemPath, + }); + + const mimeType = + fileEntity.properties[ + "https://blockprotocol.org/@blockprotocol/types/property-type/mime-type/" + ]; + + if (!mimeType) { + throw new Error( + `File entity with entityId ${fileEntity.entityId} does not have a mimeType property`, + ); + } + + const uploadedFileData = { + fileData: { + fileUri: gcpStorageUri, + mimeType, + }, + } satisfies FileDataPart; + + return uploadedFileData; + }, + ); + } + case "text": { + return { + text: content.text, + } satisfies TextPart; + } + case "tool_result": { + try { + const parsedContent = JSON.parse(content.content) as JsonValue; + + if (typeof parsedContent !== "object" || parsedContent === null) { + throw new Error("Parsed content is not an object"); + } + + return { + functionResponse: { + name: content.tool_use_id, + response: parsedContent, + }, + } satisfies FunctionResponsePart; + } catch { + throw new Error( + `Failed to parse tool result content: ${content.content}`, + ); + } + } + case "tool_use": { + return { + functionCall: { + name: content.name, + args: content.input, + }, + } satisfies FunctionCallPart; + } + } +}; diff --git a/apps/hash-ai-worker-ts/src/activities/shared/get-llm-response/get-google-ai-response/rewrite-schema-for-google.ts b/apps/hash-ai-worker-ts/src/activities/shared/get-llm-response/get-google-ai-response/rewrite-schema-for-google.ts new file mode 100644 index 00000000000..88ed15ef52e --- /dev/null +++ b/apps/hash-ai-worker-ts/src/activities/shared/get-llm-response/get-google-ai-response/rewrite-schema-for-google.ts @@ -0,0 +1,201 @@ +import { mustHaveAtLeastOne } from "@blockprotocol/type-system"; +import type { FunctionDeclarationSchema, Schema } from "@google-cloud/vertexai"; +import { SchemaType } from "@google-cloud/vertexai"; +import type { JSONSchema } from "openai/lib/jsonschema.mjs"; + +import type { LlmToolDefinition } from "../types.js"; + +/** + * @file + * + * This file is not type safe and littered with @ts-expect-errors + * + * Hopefully Google makes their schema less annoying before it becomes a problem. + */ + +/** + * The Vertex AI controlled generation (i.e. output) schema supports a subset of the Vertex AI schema fields, + * which are themselves a subset of OpenAPI schema fields. + * + * Any fields which are supported by Vertex AI but not by controlled generation will be IGNORED. + * Any fields which are not supported by Vertex AI will result in the request being REJECTED. + * + * We want to exclude the fields which will be REJECTED, i.e. are not supported at all by Vertex AI + * + * The fields which are IGNORED we allow through. Controlled generation may support them in future, + * in which case they will automatically start to be accounted for. + * + * The fields which are rejected we need to manually remove from this list when we discover that Vertex AI now supports them. + * + * There are some fields which are not listed here but instead handled specially in {@link transformSchemaForGoogle}, + * because we can rewrite them to constrain the output (e.g. 'const' can become an enum with a single option). + * + * @see https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/control-generated-output + */ +const vertexSchemaUnsupportedFields = ["$id", "multipleOf", "pattern"] as const; + +/** + * These are special fields we use in HASH but do not appear in any JSON Schema spec. + * They will never be supported in Vertex AI, so we must remove them. + */ +const nonStandardSchemaFields = [ + "abstract", + "titlePlural", + "kind", + "labelProperty", + "inverse", +]; + +const fieldsToExclude = [ + ...vertexSchemaUnsupportedFields, + ...nonStandardSchemaFields, +]; + +/** + * @see https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/control-generated-output#fields + */ +const vertexSupportedFormatValues = ["date", "date-time", "duration", "time"]; + +type SchemaValue = JSONSchema[keyof JSONSchema]; + +function assertNonBoolean(value: T): asserts value is Exclude { + if (typeof value === "boolean") { + throw new Error("Schema is a boolean"); + } +} + +export const rewriteSchemaPart = ( + schema: SchemaValue | SchemaValue[], +): SchemaValue => { + if (typeof schema !== "object" || schema === null) { + return schema; + } + + assertNonBoolean(schema); + + if (Array.isArray(schema)) { + // @ts-expect-error -- @todo fix this + return schema.map(rewriteSchemaPart); + } + + const result: Schema = {}; + + for (const [uncheckedKey, value] of Object.entries( + schema as Record, + )) { + const key = uncheckedKey === "oneOf" ? "anyOf" : uncheckedKey; + + if (key === "format" && typeof value === "string") { + if (vertexSupportedFormatValues.includes(value)) { + result[key] = value; + } else { + continue; + } + } + + if (key === "type" && typeof value === "string") { + if (value === "null") { + /** + * Google doesn't support type: "null", instead it supports nullable: boolean; + */ + throw new Error( + "type: 'null' is not supported. This should have been addressed by schema transformation.", + ); + } else { + // Google wants the type to be uppercase for some reason + result[key] = value.toUpperCase() as SchemaType; + } + } else if (fieldsToExclude.includes(key)) { + if (typeof value === "object" && value !== null) { + /** + * If the value is an object, this might well be a property which happens to have the same simplified key + * as one of our rejected fields. + */ + if ("title" in value || "titlePlural" in value) { + /** + * This is the 'inverse' field, the only one of our excluded fields which has an object value. + */ + continue; + } + // @ts-expect-error -- @todo fix this + result[key] = rewriteSchemaPart(value); + } + + /** + * If the value is not an object, we have one of our fields which will be rejected, + * not a schema. + */ + continue; + } else if (typeof value === "object" && value !== null) { + if ( + "oneOf" in value && + (value.oneOf as NonNullable).find((option) => { + if (typeof option === "object" && "type" in option) { + return option.type === "null"; + } + return false; + }) + ) { + /** + * Google doesn't support type: "null", instead it supports nullable: boolean; + * We need to transform any schema containing oneOf to add nullable: true to all its options. + */ + const newOneOf = (value.oneOf as NonNullable) + .filter((option) => { + if (typeof option === "object" && "type" in option) { + return option.type !== "null"; + } + return false; + }) + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + .map((option: NonNullable[number]) => ({ + // @ts-expect-error -- @todo fix this + ...option, + nullable: true, + })); + + if (newOneOf.length === 0) { + /** + * The Vertex AI schema requires that a schema has at least one type field, + * but does not support 'null' as a type (instead you must pass nullable: true). + * Therefore if someone happens to define a property type which ONLY accepts null, + * there is no way to represent this in the Vertex AI schema. + * + * If we define a type it will incorrect be constrainted to '{type} | null'. + */ + throw new Error( + "Property type must have at least one option which is not null", + ); + } + + (value.oneOf as NonNullable) = + mustHaveAtLeastOne(newOneOf); + } + + // @ts-expect-error -- @todo fix this + result[key] = rewriteSchemaPart(value); + } else { + // @ts-expect-error -- @todo fix this + result[key] = value; + } + } + return result; +}; + +export const rewriteSchemaForGoogle = ( + schema: LlmToolDefinition["inputSchema"], +): FunctionDeclarationSchema => { + const properties: FunctionDeclarationSchema["properties"] = {}; + + for (const [key, value] of Object.entries(schema.properties ?? {})) { + // @ts-expect-error -- @todo fix this + properties[key] = rewriteSchemaPart(value); + } + + return { + description: schema.description, + type: SchemaType.OBJECT, + properties, + required: schema.required, + }; +}; diff --git a/apps/hash-ai-worker-ts/src/activities/shared/get-llm-response/get-openai-reponse.ts b/apps/hash-ai-worker-ts/src/activities/shared/get-llm-response/get-openai-reponse.ts index b12e13d2215..a4cf43b527c 100644 --- a/apps/hash-ai-worker-ts/src/activities/shared/get-llm-response/get-openai-reponse.ts +++ b/apps/hash-ai-worker-ts/src/activities/shared/get-llm-response/get-openai-reponse.ts @@ -205,7 +205,10 @@ const openAiChatCompletionWithBackoff = async (params: { export const getOpenAiResponse = async ( params: OpenAiLlmParams, metadata: LlmRequestMetadata, -): Promise> => { +): Promise<{ + llmResponse: LlmResponse>; + transformedRequest?: undefined; +}> => { const { tools, trimMessageAtIndex, @@ -307,10 +310,27 @@ export const getOpenAiResponse = async ( } catch (error) { logger.error(`OpenAI API error: ${stringifyError(error)}`); + if (isActivityCancelled()) { + return { + llmResponse: { + status: "aborted", + provider: "openai", + }, + }; + } + + const message = + "message" in (error as Error) + ? (error as Error).message + : "Unknown error"; + return { - status: isActivityCancelled() ? "aborted" : "api-error", - provider: "openai", - error, + llmResponse: { + status: "api-error", + provider: "openai", + message, + error, + }, }; } @@ -349,15 +369,19 @@ export const getOpenAiResponse = async ( const retry = async (retryParams: { successfullyParsedToolCalls: ParsedLlmToolCall[]; retryMessageContent: LlmUserMessage["content"]; - }): Promise> => { + }): Promise<{ + llmResponse: LlmResponse; + }> => { if (retryCount > maxRetryCount) { return { - status: "exceeded-maximum-retries", - provider: "openai", - invalidResponses: previousInvalidResponses ?? [], - lastRequestTime, - totalRequestTime, - usage, + llmResponse: { + status: "exceeded-maximum-retries", + provider: "openai", + invalidResponses: previousInvalidResponses ?? [], + lastRequestTime, + totalRequestTime, + usage, + }, }; } @@ -457,13 +481,15 @@ export const getOpenAiResponse = async ( if (firstChoice.finish_reason === "length") { return { - status: "max-tokens", - provider: "openai", - response: openAiResponse, - requestMaxTokens: params.max_tokens ?? undefined, - lastRequestTime, - totalRequestTime, - usage, + llmResponse: { + status: "max-tokens", + provider: "openai", + response: openAiResponse, + requestMaxTokens: params.max_tokens ?? undefined, + lastRequestTime, + totalRequestTime, + usage, + }, }; } @@ -623,5 +649,7 @@ export const getOpenAiResponse = async ( ) ?? currentRequestTime, }; - return response; + return { + llmResponse: response, + }; }; diff --git a/apps/hash-ai-worker-ts/src/activities/shared/get-llm-response/google-vertex-ai-client.ts b/apps/hash-ai-worker-ts/src/activities/shared/get-llm-response/google-vertex-ai-client.ts new file mode 100644 index 00000000000..1f7340b2518 --- /dev/null +++ b/apps/hash-ai-worker-ts/src/activities/shared/get-llm-response/google-vertex-ai-client.ts @@ -0,0 +1,53 @@ +import type { MessageCreateParamsBase } from "@anthropic-ai/sdk/resources/messages.mjs"; +import { VertexAI } from "@google-cloud/vertexai"; + +const permittedGoogleAiModels = [ + "gemini-1.5-pro-002", +] satisfies MessageCreateParamsBase["model"][]; + +export type PermittedGoogleAiModel = (typeof permittedGoogleAiModels)[number]; + +export const isPermittedGoogleAiModel = ( + model: string, +): model is PermittedGoogleAiModel => + permittedGoogleAiModels.includes(model as PermittedGoogleAiModel); + +/** @see https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models */ +export const googleAiMessageModelToContextWindow: Record< + PermittedGoogleAiModel, + number +> = { + "gemini-1.5-pro-002": 2_097_152, +}; + +/** @see https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models */ +export const googleAiMessageModelToMaxOutput: Record< + PermittedGoogleAiModel, + number +> = { + "gemini-1.5-pro-002": 8_192, +}; +const googleCloudProjectId = process.env.GOOGLE_CLOUD_HASH_PROJECT_ID; + +let _vertexAi: VertexAI | undefined; + +export const getVertexAiClient = () => { + if (!googleCloudProjectId) { + throw new Error( + "GOOGLE_CLOUD_HASH_PROJECT_ID environment variable is not set", + ); + } + + if (_vertexAi) { + return _vertexAi; + } + + const vertexAI = new VertexAI({ + project: googleCloudProjectId, + location: "us-east4", + }); + + _vertexAi = vertexAI; + + return vertexAI; +}; diff --git a/apps/hash-ai-worker-ts/src/activities/shared/get-llm-response/llm-message.ts b/apps/hash-ai-worker-ts/src/activities/shared/get-llm-response/llm-message.ts index f09f2df7fff..2e70e8c930e 100644 --- a/apps/hash-ai-worker-ts/src/activities/shared/get-llm-response/llm-message.ts +++ b/apps/hash-ai-worker-ts/src/activities/shared/get-llm-response/llm-message.ts @@ -2,6 +2,8 @@ import type { MessageParam as AnthropicMessage, ToolResultBlockParam as AnthropicToolResultBlockParam, } from "@anthropic-ai/sdk/resources/messages"; +import type { Entity } from "@local/hash-graph-sdk/entity"; +import type { File } from "@local/hash-isomorphic-utils/system-types/shared"; import type { OpenAI } from "openai"; export type LlmMessageTextContent = { @@ -9,6 +11,13 @@ export type LlmMessageTextContent = { text: string; }; +export type LlmFileMessageContent = { + type: "file"; + fileEntity: Pick, "entityId" | "properties"> & { + metadata?: undefined; + }; +}; + export type LlmMessageToolUseContent = { type: "tool_use"; id: string; @@ -30,7 +39,11 @@ export type LlmMessageToolResultContent = { export type LlmUserMessage = { role: "user"; - content: (LlmMessageTextContent | LlmMessageToolResultContent)[]; + content: ( + | LlmMessageTextContent + | LlmMessageToolResultContent + | LlmFileMessageContent + )[]; }; export type LlmMessage = LlmAssistantMessage | LlmUserMessage; @@ -45,14 +58,18 @@ export const mapLlmMessageToAnthropicMessage = (params: { message: LlmMessage; }): AnthropicMessage => ({ role: params.message.role, - content: params.message.content.map((content) => - content.type === "tool_result" + content: params.message.content.map((content) => { + if (content.type === "file") { + throw new Error("File content not supported for Anthropic calls"); + } + + return content.type === "tool_result" ? ({ ...content, content: [{ type: "text", text: content.content }], } satisfies AnthropicToolResultBlockParam) - : content, - ), + : content; + }), }); export const mapAnthropicMessageToLlmMessage = (params: { @@ -205,6 +222,10 @@ export const mapLlmMessageToOpenAiMessages = (params: { } satisfies OpenAI.ChatCompletionUserMessageParam; } + if (content.type === "file") { + throw new Error("File content not supported for OpenAI calls"); + } + return { role: "tool", tool_call_id: content.tool_use_id, diff --git a/apps/hash-ai-worker-ts/src/activities/shared/get-llm-response/log-llm-request.ts b/apps/hash-ai-worker-ts/src/activities/shared/get-llm-response/log-llm-request.ts index 1bd4b23edf8..782736accdf 100644 --- a/apps/hash-ai-worker-ts/src/activities/shared/get-llm-response/log-llm-request.ts +++ b/apps/hash-ai-worker-ts/src/activities/shared/get-llm-response/log-llm-request.ts @@ -49,7 +49,9 @@ export const logLlmServerError = (log: LlmServerErrorLog) => { } }; -export const logLlmRequest = (log: LlmLog) => { +export const logLlmRequest = ( + log: LlmLog & { transformedRequest?: Record }, +) => { const orderedLog = { requestId: log.requestId, finalized: log.finalized, @@ -58,8 +60,9 @@ export const logLlmRequest = (log: LlmLog) => { stepId: log.stepId, secondsTaken: log.secondsTaken, response: log.response, + transformedRequest: log.transformedRequest, request: log.request, - detailedFields: ["response", "request"], + detailedFields: ["response", "request", "transformedRequest"], }; if (log.response.status === "ok") { diff --git a/apps/hash-ai-worker-ts/src/activities/shared/get-llm-response/types.ts b/apps/hash-ai-worker-ts/src/activities/shared/get-llm-response/types.ts index 7f7ae2c76d8..af185cb32fd 100644 --- a/apps/hash-ai-worker-ts/src/activities/shared/get-llm-response/types.ts +++ b/apps/hash-ai-worker-ts/src/activities/shared/get-llm-response/types.ts @@ -1,14 +1,19 @@ +import type { GenerateContentResponse } from "@google-cloud/vertexai"; import type { OpenAI } from "openai"; import type { JSONSchema } from "openai/lib/jsonschema"; import type { PermittedOpenAiModel } from "../openai-client.js"; -import type { - AnthropicApiProvider, - AnthropicMessageModel, - AnthropicMessagesCreateParams, - AnthropicMessagesCreateResponse, +import { + type AnthropicApiProvider, + type AnthropicMessagesCreateParams, + type AnthropicMessagesCreateResponse, + isPermittedAnthropicModel, + type PermittedAnthropicModel, } from "./anthropic-client.js"; -import { isAnthropicMessageModel } from "./anthropic-client.js"; +import { + isPermittedGoogleAiModel, + type PermittedGoogleAiModel, +} from "./google-vertex-ai-client.js"; import type { LlmAssistantMessage, LlmMessage } from "./llm-message.js"; export type LlmToolDefinition = { @@ -22,7 +27,10 @@ export type LlmToolDefinition = { }; export type CommonLlmParams = { - model: AnthropicMessageModel | PermittedOpenAiModel; + model: + | PermittedAnthropicModel + | PermittedOpenAiModel + | PermittedGoogleAiModel; tools?: LlmToolDefinition[]; systemPrompt?: string; messages: LlmMessage[]; @@ -34,9 +42,17 @@ export type CommonLlmParams = { }; }; +export type GoogleAiParams = + CommonLlmParams & { + model: PermittedGoogleAiModel; + previousInvalidResponses?: (GenerateContentResponse & { + requestTime: number; + })[]; + }; + export type AnthropicLlmParams = CommonLlmParams & { - model: AnthropicMessageModel; + model: PermittedAnthropicModel; maxTokens?: number; previousInvalidResponses?: (AnthropicMessagesCreateResponse & { requestTime: number; @@ -60,7 +76,8 @@ export type OpenAiLlmParams = export type LlmParams = | AnthropicLlmParams - | OpenAiLlmParams; + | OpenAiLlmParams + | GoogleAiParams; export type LlmRequestMetadata = { requestId: string; @@ -68,7 +85,7 @@ export type LlmRequestMetadata = { taskName?: string; }; -export type LlmProvider = AnthropicApiProvider | "openai"; +export type LlmProvider = AnthropicApiProvider | "openai" | "google-vertex-ai"; export type LlmLog = LlmRequestMetadata & { finalized: boolean; @@ -90,7 +107,11 @@ export type LlmServerErrorLog = LlmRequestMetadata & { export const isLlmParamsAnthropicLlmParams = ( params: LlmParams, -): params is AnthropicLlmParams => isAnthropicMessageModel(params.model); +): params is AnthropicLlmParams => isPermittedAnthropicModel(params.model); + +export const isLlmParamsGoogleAiParams = ( + params: LlmParams, +): params is GoogleAiParams => isPermittedGoogleAiModel(params.model); export type AnthropicResponse = Omit< AnthropicMessagesCreateResponse, @@ -108,6 +129,13 @@ export type OpenAiResponse = Omit< invalidResponses: (OpenAI.ChatCompletion & { requestTime: number })[]; }; +export type GoogleAiResponse = Omit< + GenerateContentResponse, + "usage" | "candidates" +> & { + invalidResponses: (GenerateContentResponse & { requestTime: number })[]; +}; + export type ParsedLlmToolCall< ToolName extends string = string, Input extends object = object, @@ -166,6 +194,7 @@ export type LlmErrorResponse = | { status: "api-error"; provider: LlmProvider; + message: string; error?: unknown; } | { @@ -209,5 +238,7 @@ export type LlmResponse = >; } & (T extends AnthropicLlmParams ? Omit - : OpenAiResponse)) + : T extends GoogleAiParams + ? GoogleAiResponse + : OpenAiResponse)) | LlmErrorResponse; diff --git a/apps/hash-ai-worker-ts/src/activities/shared/use-file-system-file-from-url.ts b/apps/hash-ai-worker-ts/src/activities/shared/use-file-system-file-from-url.ts new file mode 100644 index 00000000000..93d9cd60ecd --- /dev/null +++ b/apps/hash-ai-worker-ts/src/activities/shared/use-file-system-file-from-url.ts @@ -0,0 +1,106 @@ +import { createWriteStream } from "node:fs"; +import { mkdir, unlink } from "node:fs/promises"; +import path from "node:path"; +import { Readable } from "node:stream"; +import { finished } from "node:stream/promises"; +import type { ReadableStream } from "node:stream/web"; +import { fileURLToPath } from "node:url"; + +import { getAwsS3Config } from "@local/hash-backend-utils/aws-config"; +import { AwsS3StorageProvider } from "@local/hash-backend-utils/file-storage/aws-s3-storage-provider"; +import type { Entity } from "@local/hash-graph-sdk/entity"; +import { generateUuid } from "@local/hash-isomorphic-utils/generate-uuid"; +import type { File } from "@local/hash-isomorphic-utils/system-types/shared"; + +import { fetchFileFromUrl } from "./fetch-file-from-url.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const baseFilePath = path.join(__dirname, "/var/tmp_files"); + +export const useFileSystemPathFromEntity = async ( + fileEntity: Pick, "entityId" | "properties">, + callback: ({ + fileSystemPath, + }: { + fileSystemPath: string; + }) => Promise, +): Promise => { + const fileUrl = + fileEntity.properties[ + "https://blockprotocol.org/@blockprotocol/types/property-type/file-url/" + ]; + + if (!fileUrl) { + throw new Error( + `File entity with entityId ${fileEntity.entityId} does not have a fileUrl property`, + ); + } + + if (typeof fileUrl !== "string") { + throw new Error( + `File entity with entityId ${fileEntity.entityId} has a fileUrl property of type '${typeof fileUrl}', expected 'string'`, + ); + } + + const storageKey = + fileEntity.properties[ + "https://hash.ai/@hash/types/property-type/file-storage-key/" + ]; + + if (!storageKey) { + throw new Error( + `File entity with entityId ${fileEntity.entityId} does not have a fileStorageKey property`, + ); + } + + if (typeof storageKey !== "string") { + throw new Error( + `File entity with entityId ${fileEntity.entityId} has a fileStorageKey property of type '${typeof storageKey}', expected 'string'`, + ); + } + + await mkdir(baseFilePath, { recursive: true }); + + const filePath = `${baseFilePath}/${generateUuid()}.pdf`; + + const s3Config = getAwsS3Config(); + + const downloadProvider = new AwsS3StorageProvider(s3Config); + + const urlForDownload = await downloadProvider.presignDownload({ + entity: fileEntity, + expiresInSeconds: 60 * 60, + key: storageKey, + }); + + const fetchFileResponse = await fetchFileFromUrl(urlForDownload); + + if (!fetchFileResponse.ok || !fetchFileResponse.body) { + throw new Error( + `File entity with entityId ${fileEntity.entityId} has a fileUrl ${fileUrl} that could not be fetched: ${fetchFileResponse.statusText}`, + ); + } + + try { + const fileStream = createWriteStream(filePath); + await finished( + Readable.fromWeb( + fetchFileResponse.body as ReadableStream, + ).pipe(fileStream), + ); + } catch (error) { + await unlink(filePath); + + throw new Error( + `Failed to write file to file system: ${(error as Error).message}`, + ); + } + + const response = await callback({ fileSystemPath: filePath }); + + await unlink(filePath); + + return response; +}; diff --git a/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/migrations/007-create-api-usage-tracking.migration.ts b/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/migrations/007-create-api-usage-tracking.migration.ts index 8c560ab2800..05bc12d7929 100644 --- a/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/migrations/007-create-api-usage-tracking.migration.ts +++ b/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/migrations/007-create-api-usage-tracking.migration.ts @@ -340,6 +340,12 @@ const migrate: MigrationFunction = async ({ inputUnitCost: 0.00000025, outputUnitCost: 0.00000125, }, + { + serviceName: "Google AI", + featureName: "gemini-1.5-pro-002", + inputUnitCost: 0.00000125, + outputUnitCost: 0.000005, + }, ]; const hashOrg = await getOrgByShortname(context, authentication, { diff --git a/libs/@local/hash-backend-utils/src/file-storage.ts b/libs/@local/hash-backend-utils/src/file-storage.ts index 2ee83ab544f..4ae0e58ad78 100644 --- a/libs/@local/hash-backend-utils/src/file-storage.ts +++ b/libs/@local/hash-backend-utils/src/file-storage.ts @@ -103,7 +103,7 @@ export interface PresignedStorageRequest { /** Parameters needed to allow the download of a stored file */ export interface PresignedDownloadRequest { /** The file entity to provide a download URL for */ - entity: Entity; + entity: Pick, "properties">; /** File storage key * */ key: string; /** Expiry delay for the download authorisation */ diff --git a/libs/@local/hash-isomorphic-utils/src/numbers.ts b/libs/@local/hash-isomorphic-utils/src/numbers.ts index e1ab856285b..a0df323096a 100644 --- a/libs/@local/hash-isomorphic-utils/src/numbers.ts +++ b/libs/@local/hash-isomorphic-utils/src/numbers.ts @@ -16,7 +16,7 @@ const scaleValue = (value: number, scale: number) => { return Math.round(value * scale); }; -const maxDivisionPrecision = 10; +const maxDivisionPrecision = 16; export const divide = (numerator: number, denominator: number): number => { if (Number.isNaN(numerator) || Number.isNaN(denominator)) {