diff --git a/libs/langchain-oci-genai/.env.example b/libs/langchain-oci-genai/.env.example new file mode 100644 index 000000000000..943eb7269b45 --- /dev/null +++ b/libs/langchain-oci-genai/.env.example @@ -0,0 +1,4 @@ +# Environment variables for testing locally go here +OCI_GENAI_INTEGRATION_TESTS_COMPARTMENT_ID="compartment id where you have access to Gen AI resources and models" +OCI_GENAI_INTEGRATION_TESTS_COHERE_ON_DEMAND_MODEL_ID="A Cohere model you would like to use for the tests" +OCI_GENAI_INTEGRATION_TESTS_GENERIC_ON_DEMAND_MODEL_ID="A none Cohere model you would like to use for the tests" \ No newline at end of file diff --git a/libs/langchain-oci-genai/.eslintrc.cjs b/libs/langchain-oci-genai/.eslintrc.cjs new file mode 100644 index 000000000000..e3033ac0160c --- /dev/null +++ b/libs/langchain-oci-genai/.eslintrc.cjs @@ -0,0 +1,74 @@ +module.exports = { + extends: [ + "airbnb-base", + "eslint:recommended", + "prettier", + "plugin:@typescript-eslint/recommended", + ], + parserOptions: { + ecmaVersion: 12, + parser: "@typescript-eslint/parser", + project: "./tsconfig.json", + sourceType: "module", + }, + plugins: ["@typescript-eslint", "no-instanceof"], + ignorePatterns: [ + ".eslintrc.cjs", + "scripts", + "node_modules", + "dist", + "dist-cjs", + "*.js", + "*.cjs", + "*.d.ts", + ], + rules: { + "no-process-env": 2, + "no-instanceof/no-instanceof": 2, + "@typescript-eslint/explicit-module-boundary-types": 0, + "@typescript-eslint/no-empty-function": 0, + "@typescript-eslint/no-shadow": 0, + "@typescript-eslint/no-empty-interface": 0, + "@typescript-eslint/no-use-before-define": ["error", "nofunc"], + "@typescript-eslint/no-unused-vars": ["warn", { args: "none" }], + "@typescript-eslint/no-floating-promises": "error", + "@typescript-eslint/no-misused-promises": "error", + camelcase: 0, + "class-methods-use-this": 0, + "import/extensions": [2, "ignorePackages"], + "import/no-extraneous-dependencies": [ + "error", + { devDependencies: ["**/*.test.ts"] }, + ], + "import/no-unresolved": 0, + "import/prefer-default-export": 0, + "keyword-spacing": "error", + "max-classes-per-file": 0, + "max-len": 0, + "no-await-in-loop": 0, + "no-bitwise": 0, + "no-console": 0, + "no-restricted-syntax": 0, + "no-shadow": 0, + "no-continue": 0, + "no-void": 0, + "no-underscore-dangle": 0, + "no-use-before-define": 0, + "no-useless-constructor": 0, + "no-return-await": 0, + "consistent-return": 0, + "no-else-return": 0, + "func-names": 0, + "no-lonely-if": 0, + "prefer-rest-params": 0, + "new-cap": ["error", { properties: false, capIsNew: false }], + }, + overrides: [ + { + files: ["**/*.test.ts"], + rules: { + "@typescript-eslint/no-unused-vars": "off", + }, + }, + ], +}; diff --git a/libs/langchain-oci-genai/.gitignore b/libs/langchain-oci-genai/.gitignore new file mode 100644 index 000000000000..c10034e2f1be --- /dev/null +++ b/libs/langchain-oci-genai/.gitignore @@ -0,0 +1,7 @@ +index.cjs +index.js +index.d.ts +index.d.cts +node_modules +dist +.yarn diff --git a/libs/langchain-oci-genai/.prettierrc b/libs/langchain-oci-genai/.prettierrc new file mode 100644 index 000000000000..ba08ff04f677 --- /dev/null +++ b/libs/langchain-oci-genai/.prettierrc @@ -0,0 +1,19 @@ +{ + "$schema": "https://json.schemastore.org/prettierrc", + "printWidth": 80, + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": false, + "quoteProps": "as-needed", + "jsxSingleQuote": false, + "trailingComma": "es5", + "bracketSpacing": true, + "arrowParens": "always", + "requirePragma": false, + "insertPragma": false, + "proseWrap": "preserve", + "htmlWhitespaceSensitivity": "css", + "vueIndentScriptAndStyle": false, + "endOfLine": "lf" +} diff --git a/libs/langchain-oci-genai/.release-it.json b/libs/langchain-oci-genai/.release-it.json new file mode 100644 index 000000000000..522ee6abf705 --- /dev/null +++ b/libs/langchain-oci-genai/.release-it.json @@ -0,0 +1,10 @@ +{ + "github": { + "release": true, + "autoGenerate": true, + "tokenRef": "GITHUB_TOKEN_RELEASE" + }, + "npm": { + "versionArgs": ["--workspaces-update=false"] + } +} diff --git a/libs/langchain-oci-genai/LICENSE b/libs/langchain-oci-genai/LICENSE new file mode 100644 index 000000000000..8cd8f501eb49 --- /dev/null +++ b/libs/langchain-oci-genai/LICENSE @@ -0,0 +1,21 @@ +The MIT License + +Copyright (c) 2023 LangChain + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. \ No newline at end of file diff --git a/libs/langchain-oci-genai/README.md b/libs/langchain-oci-genai/README.md new file mode 100644 index 000000000000..888eb0c26ea1 --- /dev/null +++ b/libs/langchain-oci-genai/README.md @@ -0,0 +1,296 @@ +# @langchain/oci-genai + +Oracle Cloud Infrastructure (OCI) Generative AI is a fully managed +service that provides a set of state-of-the-art, customizable large +language models (LLMs) that cover a wide range of use cases, and which +is available through a single API. Using the OCI Generative AI service +you can access ready-to-use pretrained models, or create and host your +own fine-tuned custom models based on your own data on dedicated AI +clusters. Detailed documentation of the service and API is available +[here](https://docs.oracle.com/en-us/iaas/Content/generative-ai/home.htm) +and +[here](https://docs.oracle.com/en-us/iaas/api/#/en/generative-ai/20231130/). + +This package enables you to use OCI Generative AI in your LangChainJS applications. + +## Prerequisites + +In order to use this integration you will need the following: + +1. An OCI + tenancy. If you do not already have and account, please create one + [here](https://signup.cloud.oracle.com?sourceType=:ex:of:::::LangChainJSIntegration&SC=:ex:of:::::LangChainJSIntegration&pcode=). 2. Setup an [authentication + method](https://docs.oracle.com/en-us/iaas/Content/API/Concepts/sdk_authentication_methods.htm) + (Using a [configuration + file](https://docs.oracle.com/en-us/iaas/Content/API/Concepts/sdkconfig.htm) + with [API Key + authentication](https://docs.oracle.com/en-us/iaas/Content/API/Concepts/apisigningkey.htm#apisigningkey_topic_How_to_Generate_an_API_Signing_Key_Console) + is the simplest to start with). 3. Please make sure that your OCI + tenancy is registered in one of the [supported + regions](https://docs.oracle.com/en-us/iaas/Content/generative-ai/overview.htm#regions). 4. You will need the ID (aka OCID) of a compartment in which your OCI + user has [access to use the Generative AI + service](https://docs.oracle.com/en-us/iaas/Content/generative-ai/iam-policies.htm). + You can either use the `root` compartment or [create your + own](https://docs.oracle.com/en-us/iaas/Content/Identity/compartments/To_create_a_compartment.htm). 5. Retrieve the desired model name from the [available + models](https://docs.oracle.com/en-us/iaas/Content/generative-ai/pretrained-models.htm) + list (please make sure not to select a deprecated model). + +## Installation + +The integration makes use of the [OCI TypeScript +SDK](https://docs.oracle.com/en-us/iaas/Content/API/SDKDocs/typescriptsdk.htm). +To install the integration dependencies, execute the following: + +```bash npm2yarn +npm install oci-common oci-generativeaiinference @langchain/core @langchain/oci-genai +``` + +This package, along with the main LangChain package, depends on [`@langchain/core`](https://npmjs.com/package/@langchain/core/). +If you are using this package with other LangChain packages, you should make sure that all of the packages depend on the same instance of @langchain/core. +You can do so by adding appropriate field to your project's `package.json` like this: + +```json +{ + "name": "your-project", + "version": "0.0.0", + "dependencies": { + "@langchain/core": "^0.3.0", + "@langchain/oci-genai": "^0.0.0" + }, + "resolutions": { + "@langchain/core": "^0.3.0" + }, + "overrides": { + "@langchain/core": "^0.3.0" + }, + "pnpm": { + "overrides": { + "@langchain/core": "^0.3.0" + } + } +} +``` + +The field you need depends on the package manager you're using, but we recommend adding a field for the common `yarn`, `npm`, and `pnpm` to maximize compatibility. + +## Instantiation + +The OCI Generative AI service supports two groups of LLMs: 1. Cohere +family of LLMs. 2. Generic family of LLMs which include model such as +Llama. + +The following code demonstrates how to create an instance for each of +the families. The only mandatory two parameters are: 1. +`compartmentId` - A compartment OCID in which the user you are using for +authentication was granted permissions to access the Generative AI +service. 2. `onDemandModelId` or `dedicatedEndpointId` - Either a +[pre-trained +model](https://docs.oracle.com/en-us/iaas/Content/generative-ai/pretrained-models.htm) +name/OCID or a dedicated endpoint OCID for an endpoint configured on a +[dedicated AI cluster +(DAC)](https://docs.oracle.com/en-us/iaas/Content/generative-ai/ai-cluster.htm). +Either `onDemandModelId` or `dedicatedEndpointId` must be provided but +not both. + +In this example, since no other parameters are specified, a default SDK +client will be created with the following configuration: 1. +Authentication will be attempted using a [configuration +file](https://docs.oracle.com/en-us/iaas/Content/API/Concepts/sdkconfig.htm) +which should be already setup and available under `~/.oci/config`. The +`config` file is expected to contain a `DEFAULT` profile with the +correct information. Please see the prerequisites for more information. 2. The retry strategy will be set to a single attempt. If the first API +call was not successful, the request will fail. 3. The region will be +set to `us-chicago-1`. Please make sure that your tenancy is registered +this region. + +```ts +import { OciGenAiCohereChat, OciGenAiGenericChat } from "@langchain/oci-genai"; + +const cohereLlm = new OciGenAiCohereChat({ + compartmentId: "oci.compartment...", + onDemandModelId: "cohere.command-r-plus-08-2024", + // dedicatedEndpointId: "oci.dedicatedendpoint..." +}); + +const genericLlm = new OciGenAiGenericChat({ + compartmentId: "oci.compartment...", + onDemandModelId: "meta.llama-3.3-70b-instruct", + // dedicatedEndpointId: "oci.dedicatedendpoint..." +}); +``` + +## SDK client options + +The above example used default values to create the SDK client behind +the scenes. If you need more control in the creation of the client, here +are additional options (the options are the same for +`OciGenAiCohereChat` and `OciGenAiGenericChat`). + +The first example will create an SDK client with the following +configuration: 1. [Instance Principal +authentication](https://docs.oracle.com/en-us/iaas/Content/API/Concepts/sdk_authentication_methods.htm#sdk_authentication_methods_instance_principaldita). +Please note that this authentication method requires +[configuration](https://docs.oracle.com/en-us/iaas/Content/Identity/Tasks/callingservicesfrominstances.htm). 2. Using the Sao Paulo region. 3. Up to 3 attempts will be made in case +API calls fail. + +```ts +import { MaxAttemptsTerminationStrategy, Region } from "oci-common"; +import { + OciGenAiCohereChat, + OciGenAiNewClientAuthType, +} from "@langchain/oci-genai"; + +const cohereLlm = new OciGenAiCohereChat({ + compartmentId: "oci.compartment...", + onDemandModelId: "cohere.command-r-plus-08-2024", + newClientParams: { + authType: OciGenAiNewClientAuthType.InstancePrincipal, + regionId: Region.SA_SAOPAULO_1.regionId, + clientConfiguration: { + retryConfiguration: { + terminationStrategy: new MaxAttemptsTerminationStrategy(3), + }, + }, + }, +}); +``` + +The second example will create an SDK client with the following +configuration: 1. Config file authentication. 1. Use the config file: +`/my/path/config`. 1. Use the details under the +`MY_PROFILE_IN_CONFIG_FILE` profile in the specified config file. 1. The +retry strategy will be set to a single attempt. If the first API call +was not successful, the request will fail. 1. The region will be set to +`us-chicago-1`. Please make sure that your tenancy is registered this +region. + +```ts +import { OciGenAiCohereChat } from "@langchain/community/chat_models/oci_genai/cohere_chat"; +import { OciGenAiNewClientAuthType } from "@langchain/community/chat_models/oci_genai/types"; + +const cohereLlm = new OciGenAiCohereChat({ + compartmentId: "oci.compartment...", + onDemandModelId: "cohere.command-r-plus-08-2024", + newClientParams: { + authType: OciGenAiNewClientAuthType.ConfigFile, + authParams: { + clientConfigFilePath: "/my/path/config", + clientProfile: "MY_PROFILE_IN_CONFIG_FILE", + }, + }, +}); +``` + +The third example will create an SDK client with the following +configuration: 1. Config file authentication. 1. Use [Resource +Principal](https://docs.oracle.com/en-us/iaas/Content/API/Concepts/sdk_authentication_methods.htm#sdk_authentication_methods_resource_principal) +authentication. 1. The retry strategy will be set to a single attempt. +If the first API call was not successful, the request will fail. 1. The +region will be set to `us-chicago-1`. Please make sure that your tenancy +is registered this region. + +```ts +import { ResourcePrincipalAuthenticationDetailsProvider } from "oci-common"; +import { + OciGenAiCohereChat, + OciGenAiNewClientAuthType, +} from "@langchain/oci-genai"; + +const cohereLlm = new OciGenAiCohereChat({ + compartmentId: "oci.compartment...", + onDemandModelId: "cohere.command-r-plus-08-2024", + newClientParams: { + authType: OciGenAiNewClientAuthType.Other, + authParams: { + authenticationDetailsProvider: + ResourcePrincipalAuthenticationDetailsProvider.builder(), + }, + }, +}); +``` + +You can also instantiate the OCI Generative AI chat classes using +`GenerativeAiInferenceClient` that you create on your own. This way you +control the creation and configuration of the client to suit your +specific needs: + +```ts +import { ConfigFileAuthenticationDetailsProvider } from "oci-common"; +import { GenerativeAiInferenceClient } from "oci-generativeaiinference"; +import { OciGenAiCohereChat } from "@langchain/community/chat_models/oci_genai/cohere_chat"; + +const client = new GenerativeAiInferenceClient({ + authenticationDetailsProvider: new ConfigFileAuthenticationDetailsProvider(), +}); + +const cohereLlm = new OciGenAiCohereChat({ + compartmentId: "oci.compartment...", + onDemandModelId: "cohere.command-r-plus-08-2024", + client, +}); +``` + +## Invocation + +In this example, we make a simple call to the OCI Generative AI service +while leveraging the power of the `cohere.command-r-plus-08-2024` model. +Please note that you can pass additional request parameters under the +`requestParams` key as shown in the `invoke` call below. For more +information please see the [Cohere request +parameters](https://docs.oracle.com/en-us/iaas/api/#/en/generative-ai-inference/20231130/datatypes/CohereChatRequest) +(the `apiFormat`, `chatHistory`, `isStream`, `message` & `stopSequences` +parameters are automatically generated or inferred from the call +context) and the [Generic request +parameters](https://docs.oracle.com/en-us/iaas/api/#/en/generative-ai-inference/20231130/datatypes/GenericChatRequest) +(the `apiFormat`, `isStream`, `messages` & `stop` parameters are +automatically generated or inferred from the call context). + +If you wish to specify the chat history for a Cohere request, the list +of messages passed into the request will be analyzed and split into the +current message and history messages. The last `Human` message sent in +the list (regardless of it’s position in the list) will be considered as +the `message` parameter for the request and the rest of the messages +will be added to the `chatHistory` parameter. If there are more than one +`Human` messages, the very last one will be considered as the `message` +to be sent to the LLM in the current request and the other will be +appended to the `chatHistory`. + +```ts +import { OciGenAiCohereChat } from "@langchain/oci-genai"; + +(async () => { + const llm = new OciGenAiCohereChat({ + compartmentId: "oci.compartment...", + onDemandModelId: "cohere.command-r-plus-08-2024", + }); + + const result = await llm.invoke("Tell me a joke about beagles", { + requestParams: { + temperature: 1, + maxTokens: 300, + }, + }); + + console.log(result); +})(); +``` + +AIMessage { “content”: “Why did the beagle cross the road?he was tied to +the chicken!hope you enjoyed the joke! Would you like to hear another +one?”, “additional_kwargs”: {}, “response_metadata”: {}, “tool_calls”: +\[\], “invalid_tool_calls”: \[\] } + +## Additional information + +For additional information, please checkout the [OCI Generative AI +service +documentation](https://docs.oracle.com/en-us/iaas/Content/generative-ai/home.htm). + +If you are interested in the python version of this integration, you can +find more information +[here](https://python.langchain.com/docs/integrations/llms/oci_generative_ai/). + +## Related + +- Chat model [conceptual guide](/docs/concepts/#chat-models) +- Chat model [how-to guides](/docs/how_to/#chat-models) diff --git a/libs/langchain-oci-genai/jest.config.cjs b/libs/langchain-oci-genai/jest.config.cjs new file mode 100644 index 000000000000..994826496bc5 --- /dev/null +++ b/libs/langchain-oci-genai/jest.config.cjs @@ -0,0 +1,21 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: "ts-jest/presets/default-esm", + testEnvironment: "./jest.env.cjs", + modulePathIgnorePatterns: ["dist/", "docs/"], + moduleNameMapper: { + "^(\\.{1,2}/.*)\\.js$": "$1", + }, + transform: { + "^.+\\.tsx?$": ["@swc/jest"], + }, + transformIgnorePatterns: [ + "/node_modules/", + "\\.pnp\\.[^\\/]+$", + "./scripts/jest-setup-after-env.js", + ], + setupFiles: ["dotenv/config"], + testTimeout: 20_000, + passWithNoTests: true, + collectCoverageFrom: ["src/**/*.ts"], +}; diff --git a/libs/langchain-oci-genai/jest.env.cjs b/libs/langchain-oci-genai/jest.env.cjs new file mode 100644 index 000000000000..2ccedccb8672 --- /dev/null +++ b/libs/langchain-oci-genai/jest.env.cjs @@ -0,0 +1,12 @@ +const { TestEnvironment } = require("jest-environment-node"); + +class AdjustedTestEnvironmentToSupportFloat32Array extends TestEnvironment { + constructor(config, context) { + // Make `instanceof Float32Array` return true in tests + // to avoid https://github.com/xenova/transformers.js/issues/57 and https://github.com/jestjs/jest/issues/2549 + super(config, context); + this.global.Float32Array = Float32Array; + } +} + +module.exports = AdjustedTestEnvironmentToSupportFloat32Array; diff --git a/libs/langchain-oci-genai/langchain.config.js b/libs/langchain-oci-genai/langchain.config.js new file mode 100644 index 000000000000..46b1a2b31264 --- /dev/null +++ b/libs/langchain-oci-genai/langchain.config.js @@ -0,0 +1,22 @@ +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * @param {string} relativePath + * @returns {string} + */ +function abs(relativePath) { + return resolve(dirname(fileURLToPath(import.meta.url)), relativePath); +} + +export const config = { + internals: [/node\:/, /@langchain\/core\//], + entrypoints: { + index: "index", + }, + requiresOptionalDependency: [], + tsConfigPath: resolve("./tsconfig.json"), + cjsSource: "./dist-cjs", + cjsDestination: "./dist", + abs, +}; diff --git a/libs/langchain-oci-genai/package.json b/libs/langchain-oci-genai/package.json new file mode 100644 index 000000000000..43e6d04b66d5 --- /dev/null +++ b/libs/langchain-oci-genai/package.json @@ -0,0 +1,89 @@ +{ + "name": "@langchain/oci-genai", + "version": "0.0.1", + "description": "OCI Generative AI integration for LangChain.js", + "type": "module", + "engines": { + "node": ">=18" + }, + "main": "./index.js", + "types": "./index.d.ts", + "repository": { + "type": "git", + "url": "git@github.com:langchain-ai/langchainjs.git" + }, + "homepage": "https://github.com/langchain-ai/langchainjs/tree/main/libs/langchain-oci-genai/", + "scripts": { + "build": "yarn turbo:command build:internal --filter=@langchain/oci-genai", + "build:internal": "yarn lc_build --create-entrypoints --pre --tree-shaking", + "lint:eslint": "NODE_OPTIONS=--max-old-space-size=4096 eslint --cache --ext .ts,.js src/", + "lint:dpdm": "dpdm --exit-code circular:1 --no-warning --no-tree src/*.ts src/**/*.ts", + "lint": "yarn lint:eslint && yarn lint:dpdm", + "lint:fix": "yarn lint:eslint --fix && yarn lint:dpdm", + "clean": "rm -rf .turbo dist/", + "prepack": "yarn build", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --testPathIgnorePatterns=\\.int\\.test.ts --testTimeout 30000 --maxWorkers=50%", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch --testPathIgnorePatterns=\\.int\\.test.ts", + "test:single": "NODE_OPTIONS=--experimental-vm-modules yarn run jest --config jest.config.cjs --testTimeout 100000", + "test:int": "NODE_OPTIONS=--experimental-vm-modules jest --testPathPattern=\\.int\\.test.ts --testTimeout 100000 --maxWorkers=50%", + "format": "prettier --config .prettierrc --write \"src\"", + "format:check": "prettier --config .prettierrc --check \"src\"" + }, + "author": "LangChain", + "license": "MIT", + "dependencies": { + "oci-common": "^2.107.2", + "oci-generativeaiinference": "^2.107.2" + }, + "peerDependencies": { + "@langchain/core": ">=0.3.0 <0.4.0" + }, + "devDependencies": { + "@jest/globals": "^29.5.0", + "@langchain/core": "workspace:*", + "@langchain/scripts": ">=0.1.0 <0.2.0", + "@langchain/standard-tests": "0.0.0", + "@swc/core": "^1.3.90", + "@swc/jest": "^0.2.29", + "@tsconfig/recommended": "^1.0.3", + "@typescript-eslint/eslint-plugin": "^6.12.0", + "@typescript-eslint/parser": "^6.12.0", + "dotenv": "^16.3.1", + "dpdm": "^3.12.0", + "eslint": "^8.33.0", + "eslint-config-airbnb-base": "^15.0.0", + "eslint-config-prettier": "^8.6.0", + "eslint-plugin-import": "^2.27.5", + "eslint-plugin-no-instanceof": "^1.0.1", + "eslint-plugin-prettier": "^4.2.1", + "jest": "^29.5.0", + "jest-environment-node": "^29.6.4", + "prettier": "^2.8.3", + "release-it": "^15.10.1", + "rollup": "^4.5.2", + "ts-jest": "^29.1.0", + "typescript": "<5.2.0" + }, + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "types": { + "import": "./index.d.ts", + "require": "./index.d.cts", + "default": "./index.d.ts" + }, + "import": "./index.js", + "require": "./index.cjs" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist/", + "index.cjs", + "index.js", + "index.d.ts", + "index.d.cts" + ] +} diff --git a/libs/langchain-oci-genai/scripts/jest-setup-after-env.js b/libs/langchain-oci-genai/scripts/jest-setup-after-env.js new file mode 100644 index 000000000000..7323083d0ea5 --- /dev/null +++ b/libs/langchain-oci-genai/scripts/jest-setup-after-env.js @@ -0,0 +1,9 @@ +import { awaitAllCallbacks } from "@langchain/core/callbacks/promises"; +import { afterAll, jest } from "@jest/globals"; + +afterAll(awaitAllCallbacks); + +// Allow console.log to be disabled in tests +if (process.env.DISABLE_CONSOLE_LOGS === "true") { + console.log = jest.fn(); +} diff --git a/libs/langchain-oci-genai/src/chat_models.ts b/libs/langchain-oci-genai/src/chat_models.ts new file mode 100644 index 000000000000..2b337682a747 --- /dev/null +++ b/libs/langchain-oci-genai/src/chat_models.ts @@ -0,0 +1,221 @@ +import { AIMessageChunk, BaseMessage } from "@langchain/core/messages"; +import { ChatGenerationChunk } from "@langchain/core/outputs"; +import { SimpleChatModel } from "@langchain/core/language_models/chat_models"; +import { CallbackManagerForLLMRun } from "@langchain/core/callbacks/manager"; + +import { ChatResponse } from "oci-generativeaiinference/lib/response"; +import { ChatRequest } from "oci-generativeaiinference/lib/request"; +import { + DedicatedServingMode, + OnDemandServingMode, +} from "oci-generativeaiinference/lib/model"; + +import { + OciGenAiChatCallResponseType, + OciGenAiModelBaseParams, + OciGenAiModelCallOptions, + OciGenAiSupportedRequestType, + OciGenAiSupportedResponseType, +} from "./types.js"; + +import { OciGenAiSdkClient } from "./oci_genai_sdk_client.js"; +import { JsonServerEventsIterator } from "./server_events_iterator.js"; + +export abstract class OciGenAiBaseChat extends SimpleChatModel< + OciGenAiModelCallOptions +> { + _sdkClient: OciGenAiSdkClient | undefined; + + _params: Partial; + + constructor(params?: Partial) { + super(params ?? {}); + this._params = params ?? {}; + } + + abstract _createRequest( + messages: BaseMessage[], + options: this["ParsedCallOptions"], + stream?: boolean + ): OciGenAiSupportedRequestType; + + abstract _parseResponse( + response: OciGenAiSupportedResponseType | undefined + ): string; + + abstract _parseStreamedResponseChunk(chunk: unknown): string | undefined; + + async _call( + messages: BaseMessage[], + options: this["ParsedCallOptions"] + ): Promise { + const response: ChatResponse = await this._makeRequest(messages, options); + return this._parseResponse(response?.chatResult?.chatResponse); + } + + override async *_streamResponseChunks( + messages: BaseMessage[], + options: this["ParsedCallOptions"], + runManager?: CallbackManagerForLLMRun + ): AsyncGenerator { + const response: ReadableStream = await this._makeRequest( + messages, + options, + true + ); + const responseChunkIterator = new JsonServerEventsIterator(response); + + for await (const responseChunk of responseChunkIterator) { + yield* this._streamResponseChunk(responseChunk, runManager); + } + } + + async *_streamResponseChunk( + responseChunkData: unknown, + runManager?: CallbackManagerForLLMRun + ): AsyncGenerator { + const text: string | undefined = + this._parseStreamedResponseChunk(responseChunkData); + + if (text === undefined) { + return; + } + + yield this._createStreamResponse(text); + await runManager?.handleLLMNewToken(text); + } + + async _makeRequest( + messages: BaseMessage[], + options: this["ParsedCallOptions"], + stream?: boolean + ): Promise { + const request: OciGenAiSupportedRequestType = this._prepareRequest( + messages, + options, + stream + ); + await this._setupClient(); + return await this._chat(request); + } + + async _setupClient() { + if (this._sdkClient) { + return; + } + + this._sdkClient = await OciGenAiSdkClient.create(this._params); + } + + _createStreamResponse(text: string) { + return new ChatGenerationChunk({ + message: new AIMessageChunk({ content: text }), + text, + }); + } + + _prepareRequest( + messages: BaseMessage[], + options: this["ParsedCallOptions"], + stream?: boolean + ): OciGenAiSupportedRequestType { + this._assertMessages(messages); + return this._createRequest(messages, options, stream); + } + + _assertMessages(messages: BaseMessage[]) { + if (messages.length === 0) { + throw new Error("No messages provided"); + } + + for (const message of messages) { + if (typeof message.content !== "string") { + throw new Error("Only text messages are supported"); + } + } + } + + async _chat( + chatRequest: OciGenAiSupportedRequestType + ): Promise { + try { + return await this._callChat(chatRequest); + } catch (error) { + throw new Error( + `Error executing chat API, error: ${(error)?.message}` + ); + } + } + + async _callChat( + chatRequest: OciGenAiSupportedRequestType + ): Promise { + if (!OciGenAiBaseChat._isSdkClient(this._sdkClient)) { + throw new Error("OCI SDK client not initialized"); + } + + const fullChatRequest: ChatRequest = this._composeFullRequest(chatRequest); + return await this._sdkClient.client.chat(fullChatRequest); + } + + _composeFullRequest(chatRequest: OciGenAiSupportedRequestType): ChatRequest { + return { + chatDetails: { + chatRequest, + compartmentId: this._getCompartmentId(), + servingMode: this._getServingMode(), + }, + }; + } + + static _isSdkClient(sdkClient: unknown): sdkClient is OciGenAiSdkClient { + return ( + sdkClient !== null && + typeof sdkClient === "object" && + typeof (sdkClient).client === "object" + ); + } + + _getServingMode(): OnDemandServingMode | DedicatedServingMode { + this._assertServingMode(); + + if (typeof this._params?.onDemandModelId === "string") { + return { + servingType: OnDemandServingMode.servingType, + modelId: this._params.onDemandModelId, + }; + } + + return { + servingType: DedicatedServingMode.servingType, + endpointId: this._params.dedicatedEndpointId, + }; + } + + _getCompartmentId(): string { + if (!OciGenAiBaseChat._isValidString(this._params.compartmentId)) { + throw new Error("Invalid compartmentId"); + } + + return this._params.compartmentId; + } + + _assertServingMode() { + if ( + !OciGenAiBaseChat._isValidString(this._params.onDemandModelId) && + !OciGenAiBaseChat._isValidString(this._params.dedicatedEndpointId) + ) { + throw new Error( + "Either onDemandModelId or dedicatedEndpointId must be supplied" + ); + } + } + + static _isValidString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; + } + + _llmType() { + return "custom"; + } +} \ No newline at end of file diff --git a/libs/langchain-oci-genai/src/cohere_chat.ts b/libs/langchain-oci-genai/src/cohere_chat.ts new file mode 100644 index 000000000000..a6d5f6d7f0f3 --- /dev/null +++ b/libs/langchain-oci-genai/src/cohere_chat.ts @@ -0,0 +1,151 @@ +import { + CohereChatBotMessage, + CohereChatRequest, + CohereChatResponse, + CohereMessage, + CohereSystemMessage, + CohereUserMessage, +} from "oci-generativeaiinference/lib/model"; + +import { BaseMessage } from "@langchain/core/messages"; +import { LangSmithParams } from "@langchain/core/language_models/chat_models"; +import { OciGenAiBaseChat } from "./index.js"; + +interface HistoryMessageInfo { + chatHistory: CohereMessage[]; + message: string; +} + +interface CohereStreamedResponseChunkData { + apiFormat: string; + text: string; +} + +export type CohereCallOptions = Omit< + CohereChatRequest, + "apiFormat" | "message" | "chatHistory" | "isStream" | "stopSequences" +>; + +export class OciGenAiCohereChat extends OciGenAiBaseChat { + override _createRequest( + messages: BaseMessage[], + options: this["ParsedCallOptions"], + stream?: boolean + ): CohereChatRequest { + const historyMessage: HistoryMessageInfo = + OciGenAiCohereChat._splitMessageAndHistory(messages); + + return { + apiFormat: CohereChatRequest.apiFormat, + message: historyMessage.message, + chatHistory: historyMessage.chatHistory, + ...options.requestParams, + isStream: !!stream, + stopSequences: options.stop, + }; + } + + override _parseResponse(response: CohereChatResponse | undefined): string { + if (!OciGenAiCohereChat._isCohereResponse(response)) { + throw new Error("Invalid CohereResponse object"); + } + + return response.text; + } + + override _parseStreamedResponseChunk(chunk: unknown): string { + if (OciGenAiCohereChat._isCohereChunkData(chunk)) { + return chunk.text; + } + + throw new Error("Invalid streamed response chunk data"); + } + + static _splitMessageAndHistory(messages: BaseMessage[]): HistoryMessageInfo { + const chatHistory: CohereMessage[] = []; + let lastUserMessage = ""; + let lastUserMessageIndex = -1; + + for (let i = 0; i < messages.length; i += 1) { + const cohereMessage: CohereMessage = + this._convertBaseMessageToCohereMessage(messages[i]); + chatHistory.push(cohereMessage); + + if (cohereMessage.role === CohereUserMessage.role) { + lastUserMessage = (cohereMessage).message; + lastUserMessageIndex = i; + } + } + + if (lastUserMessageIndex !== -1) { + chatHistory.splice(lastUserMessageIndex, 1); + } + + return { + chatHistory, + message: lastUserMessage, + }; + } + + static _convertBaseMessageToCohereMessage( + baseMessage: BaseMessage + ): CohereMessage { + const messageType: string = baseMessage.getType(); + const message: string = baseMessage.content as string; + + switch (messageType) { + case "ai": + return { + role: CohereChatBotMessage.role, + message, + }; + + case "system": + return { + role: CohereSystemMessage.role, + message, + }; + + case "human": + return { + role: CohereUserMessage.role, + message, + }; + + default: + throw new Error(`Message type '${messageType}' is not supported`); + } + } + + static _isCohereResponse(response: unknown): response is CohereChatResponse { + return ( + response !== null && + typeof response === "object" && + typeof (response).text === "string" + ); + } + + static _isCohereChunkData( + chunkData: unknown + ): chunkData is CohereStreamedResponseChunkData { + return ( + chunkData !== null && + typeof chunkData === "object" && + typeof (chunkData).text === "string" && + (chunkData).apiFormat === + CohereChatRequest.apiFormat + ); + } + + override getLsParams(options: this["ParsedCallOptions"]): LangSmithParams { + return { + ls_provider: "oci_genai_cohere", + ls_model_name: + this._params.onDemandModelId || this._params.dedicatedEndpointId || "", + ls_model_type: "chat", + ls_temperature: options.requestParams?.temperature || 0, + ls_max_tokens: options.requestParams?.maxTokens || 0, + ls_stop: options.stop || [] + }; + } +} \ No newline at end of file diff --git a/libs/langchain-oci-genai/src/generic_chat.ts b/libs/langchain-oci-genai/src/generic_chat.ts new file mode 100644 index 000000000000..f2f2bde9820d --- /dev/null +++ b/libs/langchain-oci-genai/src/generic_chat.ts @@ -0,0 +1,189 @@ +import { BaseMessage } from "@langchain/core/messages"; +import { LangSmithParams } from "@langchain/core/language_models/chat_models"; + +import { + AssistantMessage, + GenericChatRequest, + GenericChatResponse, + Message, + SystemMessage, + TextContent, + UserMessage, + ChatChoice, + ChatContent, +} from "oci-generativeaiinference/lib/model"; + +import { OciGenAiBaseChat } from "./index.js"; + +export type GenericCallOptions = Omit< + GenericChatRequest, + "apiFormat" | "messages" | "isStream" | "stop" +>; + +export class OciGenAiGenericChat extends OciGenAiBaseChat { + override _createRequest( + messages: BaseMessage[], + options: this["ParsedCallOptions"], + stream?: boolean + ): GenericChatRequest { + return { + apiFormat: GenericChatRequest.apiFormat, + messages: + OciGenAiGenericChat._convertBaseMessagesToGenericMessages(messages), + ...options.requestParams, + isStream: !!stream, + stop: options.stop, + }; + } + + override _parseResponse(response: GenericChatResponse): string { + if (!OciGenAiGenericChat._isGenericResponse(response)) { + throw new Error("Invalid GenericChatResponse object"); + } + + return response.choices + ?.map((choice: ChatChoice) => + choice.message.content + ?.map((content: ChatContent) => (content).text) + .join("") + ) + .join(""); + } + + override _parseStreamedResponseChunk(chunk: unknown): string | undefined { + if (!OciGenAiGenericChat._isValidChatChoice(chunk)) { + throw new Error("Invalid streamed response chunk data"); + } + + if (OciGenAiGenericChat._isFinalChunk(chunk)) { + return undefined; + } + + return OciGenAiGenericChat._getChunkDataText(chunk); + } + + static _convertBaseMessagesToGenericMessages( + messages: BaseMessage[] + ): Message[] { + return messages.map(this._convertBaseMessageToGenericMessage); + } + + static _convertBaseMessageToGenericMessage( + baseMessage: BaseMessage + ): Message { + const messageType: string = baseMessage.getType(); + const text: string = baseMessage.content as string; + const messageRole: string = + OciGenAiGenericChat._convertBaseMessageTypeToRole(messageType); + + return OciGenAiGenericChat._createMessage(messageRole, text); + } + + static _convertBaseMessageTypeToRole(baseMessageType: string): string { + switch (baseMessageType) { + case "ai": + return AssistantMessage.role; + + case "system": + return SystemMessage.role; + + case "human": + return UserMessage.role; + + default: + throw new Error(`Message type '${baseMessageType}' is not supported`); + } + } + + static _createMessage(role: string, text: string): Message { + return { + role, + content: OciGenAiGenericChat._createTextContent(text), + }; + } + + static _createTextContent(text: string): TextContent[] { + return [ + { + type: TextContent.type, + text, + }, + ]; + } + + static _isGenericResponse( + response: unknown + ): response is GenericChatResponse { + return ( + response !== null && + typeof response === "object" && + this._isValidChoicesArray((response).choices) + ); + } + + static _isValidChoicesArray(choices: unknown): choices is ChatChoice[] { + return ( + Array.isArray(choices) && + choices.every(OciGenAiGenericChat._isValidChatChoice) + ); + } + + static _isValidChatChoice(choice: unknown): choice is ChatChoice { + return ( + choice !== null && + typeof choice === "object" && + (OciGenAiGenericChat._isValidMessage((choice).message) || + OciGenAiGenericChat._isFinalChunk(choice)) + ); + } + + static _isValidMessage(message: unknown): message is Message { + return ( + message !== null && + typeof message === "object" && + OciGenAiGenericChat._isValidContentArray((message).content) + ); + } + + static _isValidContentArray(content: TextContent[] | undefined): boolean { + return ( + Array.isArray(content) && + content.every(OciGenAiGenericChat._isValidTextContent) + ); + } + + static _isValidTextContent(content: unknown): content is TextContent { + return ( + content !== null && + typeof content === "object" && + (content).type === TextContent.type && + typeof (content).text === "string" + ); + } + + static _getChunkDataText(chunkData: ChatChoice): string | undefined { + return chunkData.message?.content + ?.map((message: TextContent) => message.text) + .join(" "); + } + + static _isFinalChunk(chunkData: unknown) { + return ( + chunkData !== null && + typeof chunkData === "object" && + typeof (chunkData).finishReason === "string" + ); + } + + override getLsParams(options: this["ParsedCallOptions"]): LangSmithParams { + return { + ls_provider: "oci_genai_generic", + ls_model_name: + this._params.onDemandModelId || this._params.dedicatedEndpointId || "", + ls_model_type: "chat", + ls_temperature: options.requestParams?.temperature || 0, + ls_max_tokens: options.requestParams?.maxTokens || 0, + ls_stop: options.stop || [], + }; + } +} \ No newline at end of file diff --git a/libs/langchain-oci-genai/src/index.ts b/libs/langchain-oci-genai/src/index.ts new file mode 100644 index 000000000000..571744457dda --- /dev/null +++ b/libs/langchain-oci-genai/src/index.ts @@ -0,0 +1,4 @@ +export * from "./chat_models.js"; +export * from "./cohere_chat.js"; +export * from "./generic_chat.js"; +export * from "./types.js"; diff --git a/libs/langchain-oci-genai/src/oci_genai_sdk_client.ts b/libs/langchain-oci-genai/src/oci_genai_sdk_client.ts new file mode 100644 index 000000000000..dbb2685e85ac --- /dev/null +++ b/libs/langchain-oci-genai/src/oci_genai_sdk_client.ts @@ -0,0 +1,129 @@ +import { + AuthenticationDetailsProvider, + AuthParams, + ClientConfiguration, + ConfigFileAuthenticationDetailsProvider, + InstancePrincipalsAuthenticationDetailsProviderBuilder, + MaxAttemptsTerminationStrategy, + Region, +} from "oci-common"; + +import { GenerativeAiInferenceClient } from "oci-generativeaiinference"; + +import { + ConfigFileAuthParams, + OciGenAiClientParams, + OciGenAiNewClientAuthType, +} from "./types.js"; + +export class OciGenAiSdkClient { + static readonly _DEFAULT_REGION_ID = Region.US_CHICAGO_1.regionId; + + private constructor(private _client: GenerativeAiInferenceClient) { } + + get client(): GenerativeAiInferenceClient { + return this._client; + } + + static async create( + params: OciGenAiClientParams + ): Promise { + const client: GenerativeAiInferenceClient = await this._getClient(params); + return new OciGenAiSdkClient(client); + } + + static async _getClient( + params: OciGenAiClientParams + ): Promise { + if (params.client) { + return params.client; + } + + return await this._createAndSetupNewClient(params); + } + + static async _createAndSetupNewClient( + params: OciGenAiClientParams + ): Promise { + const client: GenerativeAiInferenceClient = await this._createNewClient( + params + ); + + if (!params.newClientParams?.regionId) { + client.regionId = this._DEFAULT_REGION_ID; + } else { + client.regionId = params.newClientParams.regionId; + } + + return client; + } + + static async _createNewClient( + params: OciGenAiClientParams + ): Promise { + const authParams: AuthParams = await this._getClientAuthParams(params); + const clientConfiguration: ClientConfiguration = + this._getClientConfiguration(params.newClientParams?.clientConfiguration); + return new GenerativeAiInferenceClient(authParams, clientConfiguration); + } + + static async _getClientAuthParams( + params: OciGenAiClientParams + ): Promise { + if (params.newClientParams?.authType === OciGenAiNewClientAuthType.Other) { + return params.newClientParams.authParams; + } + + const authenticationDetailsProvider: AuthenticationDetailsProvider = + await this._getAuthProvider(params); + return { authenticationDetailsProvider }; + } + + static async _getAuthProvider( + params: OciGenAiClientParams + ): Promise { + switch (params.newClientParams?.authType) { + case undefined: + case OciGenAiNewClientAuthType.ConfigFile: + return this._getConfigFileAuthProvider(params); + + case OciGenAiNewClientAuthType.InstancePrincipal: + return await this._getInstancePrincipalAuthProvider(); + + default: + throw new Error("Invalid authentication type"); + } + } + + static _getConfigFileAuthProvider( + params: OciGenAiClientParams + ): AuthenticationDetailsProvider { + const configFileAuthParams: ConfigFileAuthParams = ( + params.newClientParams?.authParams + ); + return new ConfigFileAuthenticationDetailsProvider( + configFileAuthParams?.clientConfigFilePath, + configFileAuthParams?.clientProfile + ); + } + + static async _getInstancePrincipalAuthProvider(): Promise { + const instancePrincipalAuthenticationBuilder = + new InstancePrincipalsAuthenticationDetailsProviderBuilder(); + return await instancePrincipalAuthenticationBuilder.build(); + } + + static _getClientConfiguration( + clientConfiguration: ClientConfiguration | undefined + ): ClientConfiguration { + if (clientConfiguration) { + return clientConfiguration; + } + + return { + retryConfiguration: { + terminationStrategy: new MaxAttemptsTerminationStrategy(1), + }, + }; + } +} \ No newline at end of file diff --git a/libs/langchain-oci-genai/src/server_events_iterator.ts b/libs/langchain-oci-genai/src/server_events_iterator.ts new file mode 100644 index 000000000000..9adcd9fff320 --- /dev/null +++ b/libs/langchain-oci-genai/src/server_events_iterator.ts @@ -0,0 +1,76 @@ +import { IterableReadableStream } from "@langchain/core/utils/stream"; + +export class JsonServerEventsIterator { + static readonly _SERVER_EVENT_DATA_PREFIX: string = "data: "; + + static readonly _SERVER_EVENT_DATA_PREFIX_LENGTH: number = + this._SERVER_EVENT_DATA_PREFIX.length; + + _eventsStream: IterableReadableStream; + + _textDecoder: TextDecoder; + + constructor(sourceStream: ReadableStream) { + this._eventsStream = + IterableReadableStream.fromReadableStream(sourceStream); + this._textDecoder = new TextDecoder(); + } + + async *[Symbol.asyncIterator](): AsyncIterator { + for await (const eventRawData of this._eventsStream) { + yield this._parseEvent(eventRawData); + } + } + + _parseEvent(eventRawData: Uint8Array): unknown { + const eventDataText: string = this._getEventDataText(eventRawData); + const eventData: unknown = + JsonServerEventsIterator._getEventDataAsJson(eventDataText); + JsonServerEventsIterator._assertEventData(eventData); + + return eventData; + } + + _getEventDataText(eventData: Uint8Array): string { + JsonServerEventsIterator._assertEventRawData(eventData); + const eventDataText: string = this._textDecoder.decode(eventData); + JsonServerEventsIterator._assertEventText(eventDataText); + return eventDataText; + } + + static _assertEventRawData(eventRawData: Uint8Array) { + if (eventRawData.length < this._SERVER_EVENT_DATA_PREFIX_LENGTH) { + throw new Error("Event raw data is empty or too short to be valid"); + } + } + + static _assertEventText(eventText: string) { + if ( + eventText.length < this._SERVER_EVENT_DATA_PREFIX_LENGTH || + !eventText.startsWith(this._SERVER_EVENT_DATA_PREFIX) + ) { + throw new Error("Event text is empty, too short or malformed"); + } + } + + static _assertEventData(eventData: unknown) { + if (eventData === null || typeof eventData !== "object") { + throw new Error("Event data could not be parsed into an object"); + } + } + + static _getEventDataAsJson(eventDataText: string): unknown { + try { + const eventJsonText: string = this._getEventJsonText(eventDataText); + return JSON.parse(eventJsonText); + } catch { + throw new Error("Could not parse event data as JSON"); + } + } + + static _getEventJsonText(eventDataText: string): string { + return eventDataText.substring( + JsonServerEventsIterator._SERVER_EVENT_DATA_PREFIX_LENGTH + ); + } +} \ No newline at end of file diff --git a/libs/langchain-oci-genai/src/tests/chatoci_genai.int.test.ts b/libs/langchain-oci-genai/src/tests/chatoci_genai.int.test.ts new file mode 100644 index 000000000000..2429a6177c39 --- /dev/null +++ b/libs/langchain-oci-genai/src/tests/chatoci_genai.int.test.ts @@ -0,0 +1,67 @@ +/* eslint-disable no-process-env */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { BaseChatModel } from "@langchain/core/language_models/chat_models"; + +import { OciGenAiCohereChat } from "../cohere_chat.js"; +import { OciGenAiGenericChat } from "../generic_chat.js"; + +type OciGenAiChatConstructor = new (args: any) => BaseChatModel; + +/* + * OciGenAiChat tests + */ + +const compartmentId = process.env.OCI_GENAI_INTEGRATION_TESTS_COMPARTMENT_ID; +const creationParameters = [ + [ + { + compartmentId, + onDemandModelId: + process.env.OCI_GENAI_INTEGRATION_TESTS_COHERE_ON_DEMAND_MODEL_ID, + }, + ], + [ + { + compartmentId, + onDemandModelId: + process.env.OCI_GENAI_INTEGRATION_TESTS_GENERIC_ON_DEMAND_MODEL_ID, + }, + ], +]; + +test("OCI GenAI chat invoke", async () => { + await testEachChatModelType( + async (ChatClassType: OciGenAiChatConstructor, creationParams: any[]) => { + for (const params of creationParams) { + const chatClass = new ChatClassType(params); + const response = await chatClass.invoke( + "generate a single, very short mission statement for a pet insurance company" + ); + expect(response.content.length).toBeGreaterThan(0); + } + }, + creationParameters + ); +}); + +/* + * Utils + */ + +async function testEachChatModelType( + testFunction: ( + ChatClassType: OciGenAiChatConstructor, + parameter?: any | undefined + ) => Promise, + parameters?: any[] +) { + const chatClassTypes: OciGenAiChatConstructor[] = [ + OciGenAiCohereChat, + OciGenAiGenericChat, + ]; + + for (let i = 0; i < chatClassTypes.length; i += 1) { + await testFunction(chatClassTypes[i], parameters?.at(i)); + } +} \ No newline at end of file diff --git a/libs/langchain-oci-genai/src/tests/chatoci_genai.standard.int.test.ts b/libs/langchain-oci-genai/src/tests/chatoci_genai.standard.int.test.ts new file mode 100644 index 000000000000..75d8912c13dc --- /dev/null +++ b/libs/langchain-oci-genai/src/tests/chatoci_genai.standard.int.test.ts @@ -0,0 +1,79 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +/* eslint-disable no-process-env */ +import { test, expect } from "@jest/globals"; + +import { AIMessageChunk } from "@langchain/core/messages"; +import { BaseChatModelCallOptions } from "@langchain/core/language_models/chat_models"; +import { ChatModelIntegrationTests } from "@langchain/standard-tests"; + +import { OciGenAiCohereChat } from "../cohere_chat.js"; +import { OciGenAiGenericChat } from "../generic_chat.js"; + +type OciGenAiChatConstructor = new (args: any) => + | OciGenAiCohereChat + | OciGenAiGenericChat; + +class OciGenAiChatStandardIntegrationTests extends ChatModelIntegrationTests< + BaseChatModelCallOptions, + AIMessageChunk +> { + constructor( + classTypeToTest: OciGenAiChatConstructor, + private classTypeName: string, + onDemandModelId: string + ) { + super({ + Cls: classTypeToTest, + chatModelHasToolCalling: false, + chatModelHasStructuredOutput: false, + supportsParallelToolCalls: false, + constructorArgs: { + compartmentId: process.env.OCI_GENAI_INTEGRATION_TESTS_COMPARTMENT_ID, + onDemandModelId, + }, + }); + } + + async testCacheComplexMessageTypes() { + this._skipTestMessage("testCacheComplexMessageTypes"); + } + + async testStreamTokensWithToolCalls() { + this._skipTestMessage("testStreamTokensWithToolCalls"); + } + + async testUsageMetadata() { + this._skipTestMessage("testUsageMetadata"); + } + + async testUsageMetadataStreaming() { + this._skipTestMessage("testUsageMetadataStreaming"); + } + + _skipTestMessage(testName: string) { + this.skipTestMessage(testName, this.classTypeName, "Not implemented"); + } +} + +const ociGenAiCohereChatTestClass = new OciGenAiChatStandardIntegrationTests( + OciGenAiCohereChat, + "OciGenAiCohereChat", + process.env.OCI_GENAI_INTEGRATION_TESTS_COHERE_ON_DEMAND_MODEL_ID! +); + +test("ociGenAiCohereChatTestClass", async () => { + const testResults = await ociGenAiCohereChatTestClass.runTests(); + expect(testResults).toBe(true); +}); + +const ociGenAiGenericChatTestClass = new OciGenAiChatStandardIntegrationTests( + OciGenAiGenericChat, + "OciGenAiGenericChat", + process.env.OCI_GENAI_INTEGRATION_TESTS_GENERIC_ON_DEMAND_MODEL_ID! +); + +test("ociGenAiGenericChatTestClass", async () => { + const testResults = await ociGenAiGenericChatTestClass.runTests(); + expect(testResults).toBe(true); +}); \ No newline at end of file diff --git a/libs/langchain-oci-genai/src/tests/chatoci_genai.standard.test.ts b/libs/langchain-oci-genai/src/tests/chatoci_genai.standard.test.ts new file mode 100644 index 000000000000..e26e7bc58ab7 --- /dev/null +++ b/libs/langchain-oci-genai/src/tests/chatoci_genai.standard.test.ts @@ -0,0 +1,54 @@ +import { test, expect } from "@jest/globals"; +import { ChatModelUnitTests } from "@langchain/standard-tests"; +import { AIMessageChunk } from "@langchain/core/messages"; +import { BaseChatModelCallOptions } from "@langchain/core/language_models/chat_models"; +import { OciGenAiCohereChat } from "../cohere_chat.js"; +import { OciGenAiGenericChat } from "../generic_chat.js"; + +class OciGenAiCohereChatStandardUnitTests extends ChatModelUnitTests< + BaseChatModelCallOptions, + AIMessageChunk +> { + constructor() { + super({ + Cls: OciGenAiCohereChat, + chatModelHasToolCalling: false, + chatModelHasStructuredOutput: false, + constructorArgs: { + compartmentId: "oci.compartment.ocid", + onDemandModelId: "oci.model.ocid", + }, + }); + } +} + +const ociGenAiCohereTestClass = new OciGenAiCohereChatStandardUnitTests(); + +test("OciGenAiCohereChatStandardUnitTests", () => { + const testResults = ociGenAiCohereTestClass.runTests(); + expect(testResults).toBe(true); +}); + +class OciGenAiGenericChatStandardUnitTests extends ChatModelUnitTests< + BaseChatModelCallOptions, + AIMessageChunk +> { + constructor() { + super({ + Cls: OciGenAiGenericChat, + chatModelHasToolCalling: false, + chatModelHasStructuredOutput: false, + constructorArgs: { + compartmentId: "oci.compartment.ocid", + onDemandModelId: "oci.model.ocid", + }, + }); + } +} + +const ociGenAiGenericTestClass = new OciGenAiGenericChatStandardUnitTests(); + +test("OciGenAiGenericChatStandardUnitTests", () => { + const testResults = ociGenAiGenericTestClass.runTests(); + expect(testResults).toBe(true); +}); \ No newline at end of file diff --git a/libs/langchain-oci-genai/src/tests/chatoci_genai.test.ts b/libs/langchain-oci-genai/src/tests/chatoci_genai.test.ts new file mode 100644 index 000000000000..2a97f6d49c51 --- /dev/null +++ b/libs/langchain-oci-genai/src/tests/chatoci_genai.test.ts @@ -0,0 +1,1429 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + AIMessage, + BaseMessage, + HumanMessage, + HumanMessage as LangChainHumanMessage, + SystemMessage as LangChainSystemMessage, + ToolMessage as LangChainToolMessage, + SystemMessage, + ToolMessage, +} from "@langchain/core/messages"; + +import { GenerativeAiInferenceClient } from "oci-generativeaiinference"; +import { + CohereChatRequest, + CohereSystemMessage as OciGenAiCohereSystemMessage, + CohereUserMessage as OciGenAiCohereUserMessage, + Message, + GenericChatRequest, + TextContent, + CohereMessage, + CohereChatBotMessage, + CohereSystemMessage, + CohereUserMessage, + AssistantMessage as GenericAssistantMessage, + UserMessage as GenericUserMessage, + SystemMessage as GenericSystemMessage, +} from "oci-generativeaiinference/lib/model"; + +import { MaxAttemptsTerminationStrategy } from "oci-common"; + +import { OciGenAiBaseChat } from "../index.js"; +import { OciGenAiCohereChat } from "../cohere_chat.js"; +import { OciGenAiGenericChat } from "../generic_chat.js"; +import { JsonServerEventsIterator } from "../server_events_iterator.js"; +import { OciGenAiSdkClient } from "../oci_genai_sdk_client.js"; +import { + OciGenAiClientParams, + OciGenAiNewClientAuthType, +} from "../types.js"; + +type OciGenAiChatConstructor = new (args: any) => + | OciGenAiCohereChat + | OciGenAiGenericChat; + +/* + * JsonServerEventsIterator tests + */ + +const invalidServerEvents: string[][] = [ + [{} as string], + ["invalid event data", 'data: {"test":5}'], + ['{"prop":"val"}'], + [""], + [" "], + [' ata: {"final": true}'], + ['data {"prop":"val"}'], + ['data: {"prop":"val"'], + ["data:"], + ["data: "], + ["data: 5"], + ["data: fail"], + ['data: "testing 1, 2, 3"'], + ["data: null"], + ["data: -345.345345"], + ["\u{1F600}e\u0301"], +]; + +const invalidEventDataErrors = new RegExp( + "Event text is empty, too short or malformed|" + + "Event data is empty or too short to be valid|" + + "Could not parse event data as JSON|" + + "Event raw data is empty or too short to be valid|" + + "Event data could not be parsed into an object" +); + +const validServerEvents: string[] = [ + 'data: {"test":5}', + 'data: {"message":"this is a message"}', + 'data: {"finalReason":"i j`us`t felt like stopping", "terminate": true}', + "data: {}", + 'data: \n{"message":"this is a message"\n,"ignore":{"yes":"no"}}', +]; + +interface ValidServerEventProps { + finalReason: string; + terminate: boolean; +} + +const validServerEventsProps: string[] = [ + `data: ${JSON.stringify({ + finalReason: "reason 1", + terminate: true, + })}`, + `data: ${JSON.stringify({ + finalReason: "this is a message", + terminate: true, + })}`, + `data: ${JSON.stringify({ + finalReason: "i just felt like stopping", + terminate: true, + })}`, +]; + +test("JsonServerEventsIterator invalid events", async () => { + for (const values of invalidServerEvents) { + const stream: ReadableStream = + createStreamFromStringArray(values); + const streamIterator = new JsonServerEventsIterator(stream); + await testInvalidValues(streamIterator); + } +}); + +test("JsonServerEventsIterator empty events", async () => { + await testNumExpectedServerEvents([], 0); +}); + +test("JsonServerEventsIterator valid events", async () => { + await testNumExpectedServerEvents( + validServerEvents, + validServerEvents.length + ); +}); + +test("JsonServerEventsIterator valid events check properties", async () => { + const stream: ReadableStream = createStreamFromStringArray( + validServerEventsProps + ); + const streamIterator = new JsonServerEventsIterator(stream); + + for await (const event of streamIterator) { + expect(typeof (event).finalReason).toBe("string"); + expect((event).terminate).toBe(true); + } +}); + +/* + * OciGenAiSdkClient tests + */ + +const authenticationDetailsProvider = { + getPassphrase() { + return ""; + }, + async getKeyId(): Promise { + return ""; + }, + getPrivateKey() { + return `-----BEGIN RSA PRIVATE KEY----- +MIICXQIBAAKBgQDTkUM7vYZSUYtm2bY/OmcvF9dQ37I3HMyKIKmFPck7Q4u5LqPB +qTuDNnd0tHBFfRaGpVsgcT46g1sIJwvfCnB5VFkAsheMHc8uUOBUD0DqBbkOLFGU +KI45rD0BUzOzjRW/NI5YFWUJJZGuD7tUP1gEwmr0wIvqTdpPI/CyN0pUTQIDAQAB +AoGAJzg1g3yVyurM8csIKt5zxFoiEx701ZykGjMF2epjRHY4D6MivkLWAnP1XxAY +A/m1VE6Q/wmfJI+3L2K1o6o2wSDUqbU+qW3xHVxc3U63JpUBa2MFQaupriEaA8ky +4iq5Zhs2OlRL02+A9KHvfus6MFhWWPLnkNrSx8cIaJycGgECQQDyFIuB9z76OUCU +B63TbqeRhzbBsVUc/6hErWacb4JCUtGk6s141l5V5pDNO2+w3mQ6HxqWLSct+19t +5BormrDNAkEA37uQj+OkjYBoeGEuB00PJBnlUIaQ/qHv7863aLlKcFdnFvmrzztA +A06QhjNCFBwJHwdSLz95ztDTpccmLIAxgQJBAO/Q4pOR+FWyugLryIwYpvBIXzpr +DsJ3kp7WmTyISyahHQafhYYb98BpdTGbm/4/klLx1UjI2nN2/wbCXhqsWFECQAu/ +PGLhr/UiBdo0OAd4G1Bo76pftmM4O3Ha57Re7jKh1C7Xoxa5ZK4HxPzW2iRWKIBx +kPYcHhgmzMYKg82YWYECQQCejFaH73vZO3qUn+2pdHg3mUYYYQA7r/ms7MQ7mckg +1wPuzmfsEfsAzOaMvs8SsyG5sOdBLWfsGRabFaleBntX +-----END RSA PRIVATE KEY-----`; + }, +}; + +const defaultClient = { + newClientParams: { + authType: OciGenAiNewClientAuthType.Other, + authParams: { authenticationDetailsProvider }, + }, +}; + +test("OciGenAiSdkClient create default client", async () => { + const sdkClient = await OciGenAiSdkClient.create(defaultClient); + testSdkClient(sdkClient, OciGenAiSdkClient._DEFAULT_REGION_ID, 0); +}); + +test("OciGenAiSdkClient create client based on parameters", async () => { + const newClientParams: OciGenAiClientParams = { + newClientParams: { + authType: OciGenAiNewClientAuthType.Other, + regionId: "mars", + authParams: { authenticationDetailsProvider }, + clientConfiguration: { + retryConfiguration: { + terminationStrategy: new MaxAttemptsTerminationStrategy(5), + }, + }, + }, + }; + + const sdkClient = await OciGenAiSdkClient.create(newClientParams); + testSdkClient(sdkClient, "mars", 4); +}); + +test("OciGenAiSdkClient create client based on some parameters #2", async () => { + const sdkClient = await OciGenAiSdkClient.create(defaultClient); + testSdkClient(sdkClient, OciGenAiSdkClient._DEFAULT_REGION_ID, 0); +}); + +test("OciGenAiSdkClient pre-configured client", async () => { + const client = new GenerativeAiInferenceClient( + { authenticationDetailsProvider }, + { + retryConfiguration: { + terminationStrategy: new MaxAttemptsTerminationStrategy(10), + }, + } + ); + + client.regionId = "venus"; + const sdkClient = await OciGenAiSdkClient.create({ client }); + testSdkClient(sdkClient, "venus", 9); +}); + +/* + * Chat models tests + */ + +const compartmentId = "oci.compartment.ocid"; +const onDemandModelId = "oci.model.ocid"; +const dedicatedEndpointId = "oci.dedicated.oci"; +const createParams = { + compartmentId, + onDemandModelId, +}; + +const DummyClient = { + chat() { }, +}; + +test("OCI GenAI chat models creation", async () => { + await testEachChatModelType( + async (ChatClassType: OciGenAiChatConstructor) => { + let instance = new ChatClassType({ client: DummyClient }); + await expect(instance.invoke("prompt")).rejects.toThrow( + "Invalid compartmentId" + ); + + instance = new ChatClassType({ + compartmentId, + client: DummyClient, + }); + + await expect(instance.invoke("prompt")).rejects.toThrow( + "Either onDemandModelId or dedicatedEndpointId must be supplied" + ); + + instance = new ChatClassType({ + compartmentId, + onDemandModelId: "", + client: DummyClient, + }); + + await expect(instance.invoke("prompt")).rejects.toThrow( + "Either onDemandModelId or dedicatedEndpointId must be supplied" + ); + + instance = new ChatClassType({ + compartmentId, + onDemandModelId, + client: DummyClient, + }); + + await expect(instance.invoke("prompt")).rejects.toThrow( + /Invalid CohereResponse object|Invalid GenericChatResponse object/ + ); + + expect(instance._params.compartmentId).toBe(compartmentId); + expect(instance._params.onDemandModelId).toBe(onDemandModelId); + } + ); +}); + +const chatClassReturnValues = [ + { + chatResult: { + chatResponse: { + text: "response text", + }, + }, + }, + { + chatResult: { + chatResponse: { + choices: [ + { + message: { + content: [ + { + type: TextContent.type, + text: "response text", + }, + ], + }, + }, + ], + }, + }, + }, +]; + +test("OCI GenAI chat models invoke with unsupported message", async () => { + await testEachChatModelType( + async (ChatClassType: OciGenAiChatConstructor) => { + const chatClass = new ChatClassType(createParams); + + await expect( + chatClass.invoke([ + new LangChainToolMessage({ content: "tools message" }, "tool_id"), + ]) + ).rejects.toThrow("Message type 'tool' is not supported"); + }, + chatClassReturnValues + ); +}); + +const lastHumanMessage = "Last human message"; +const messages = [ + new LangChainHumanMessage("Human message"), + new LangChainSystemMessage("System message"), + new LangChainSystemMessage("System message"), + new LangChainHumanMessage(lastHumanMessage), + new LangChainSystemMessage("System message"), + new LangChainSystemMessage("System message"), +]; + +const callOptions = { + stop: ["\n", "."], + requestParams: { + temperature: 0.32, + maxTokens: 1, + }, +}; + +const createRequestParams = [ + { + test: (cohereRequest: CohereChatRequest, params: any) => { + expect(cohereRequest.apiFormat).toBe(CohereChatRequest.apiFormat); + expect(cohereRequest.message).toBe(lastHumanMessage); + expect(cohereRequest.chatHistory).toStrictEqual( + removeElements(params.convertMessages(messages), [3]) + ); + expect(cohereRequest.isStream).toBe(true); + expect(cohereRequest.stopSequences).toStrictEqual(callOptions.stop); + expect(cohereRequest.temperature).toBe( + callOptions.requestParams.temperature + ); + expect(cohereRequest.maxTokens).toBe(callOptions.requestParams.maxTokens); + }, + convertMessages: (messages: BaseMessage[]): Message[] => messages.map( + OciGenAiCohereChat._convertBaseMessageToCohereMessage + ), + }, + { + test: (genericRequest: GenericChatRequest, params: any) => { + expect(genericRequest.apiFormat).toBe(GenericChatRequest.apiFormat); + expect(genericRequest.messages).toStrictEqual( + params.convertMessages(messages) + ); + expect(genericRequest.isStream).toBe(true); + expect(genericRequest.stop).toStrictEqual(callOptions.stop); + expect(genericRequest.temperature).toBe( + callOptions.requestParams.temperature + ); + expect(genericRequest.maxTokens).toBe( + callOptions.requestParams.maxTokens + ); + }, + convertMessages: (messages: BaseMessage[]): Message[] => messages.map( + OciGenAiGenericChat._convertBaseMessageToGenericMessage + ), + }, +]; + +const invalidMessages = [ + [], + [ + new LangChainToolMessage("Human message", "tool"), + new LangChainSystemMessage("System message"), + new LangChainSystemMessage("System message"), + new LangChainHumanMessage(lastHumanMessage), + new LangChainSystemMessage("System message"), + new LangChainSystemMessage("System message"), + ], + [ + new LangChainSystemMessage({ + content: [ + { + type: "image_url", + image_url: "data:image/pgn;base64,blah", + }, + ], + }), + ], +]; + +test("OCI GenAI chat create request", async () => { + await testEachChatModelType( + async (ChatClassType: OciGenAiChatConstructor, params) => { + const chatClass = new ChatClassType(createParams); + const request = chatClass._prepareRequest(messages, callOptions, true); + params.test(request, params); + }, + createRequestParams + ); +}); + +test("OCI GenAI chat create invalid request messages", async () => { + await testEachChatModelType( + async (ChatClassType: OciGenAiChatConstructor) => { + const chatClass = new ChatClassType(createParams); + expect(() => + chatClass._prepareRequest(invalidMessages[0], callOptions, true) + ).toThrow("No messages provided"); + expect(() => + chatClass._prepareRequest(invalidMessages[1], callOptions, true) + ).toThrow("Message type 'tool' is not supported"); + expect(() => + chatClass._prepareRequest(invalidMessages[2], callOptions, true) + ).toThrow("Only text messages are supported"); + } + ); +}); + +const invalidCohereResponseValues = [ + undefined, + null, + {}, + { props: true }, + { text: 5505 }, + { text: ["hello "] }, + [], +]; + +test("OCI GenAI chat Cohere parse invalid response", async () => { + const cohereChat = new OciGenAiCohereChat(createParams); + + for (const invalidValue of invalidCohereResponseValues) { + expect(() => cohereChat._parseResponse(invalidValue)).toThrow( + "Invalid CohereResponse object" + ); + } +}); + +const validCohereResponseValues = [ + { + apiFormat: CohereChatRequest.apiFormat, + value: undefined, + text: "This is the response text", + }, + { + text: "This is the response text", + }, +]; + +test("OCI GenAI Cohere parse valid response", async () => { + const cohereChat = new OciGenAiCohereChat(createParams); + + for (const validValue of validCohereResponseValues) { + expect(cohereChat._parseResponse(validValue)).toBe( + "This is the response text" + ); + } +}); + +const invalidCGenericResponseValues = [ + undefined, + null, + {}, + [], + { props: true }, + { choices: 5505 }, + { choices: ["hello "] }, + { choices: null }, + { choices: {} }, + { + choices: [ + { + content: undefined, + }, + ], + }, + { + message: { + content: {}, + }, + }, + { + message: { + content: [], + }, + }, + { finishReason: {} }, + { finishReason: false }, + { + choices: [5], + }, + { + choices: [ + { + message: "bad value", + }, + ], + }, + { + choices: [ + { + message: {}, + }, + ], + }, + { + choices: [ + { + message: null, + }, + ], + }, + { + choices: [ + { + message: { + content: null, + }, + }, + ], + }, + { + choices: [ + { + message: { + content: [{}], + }, + }, + ], + }, + { + choices: [ + { + message: { + content: [null], + }, + }, + ], + }, + { + choices: [ + { + message: { + content: [ + { + text: "some text", + }, + ], + }, + }, + ], + }, + { + choices: [ + { + message: { + content: [ + { + type: "IMAGE", + text: "some text", + }, + ], + }, + }, + ], + }, + { + choices: [ + { + message: { + content: [ + { + type: TextContent.type, + text: [1, 2, 3, 4], + }, + ], + }, + }, + ], + }, + { + choices: [ + { + message: { + content: [ + { + type: TextContent.type, + text: null, + }, + ], + }, + }, + ], + }, + { + choices: [ + { + message: { + content: [ + { + type: TextContent.type, + text: "This is ", + }, + ], + }, + }, + { + message: { + content: [ + { + type: TextContent.type, + text: false, + }, + ], + }, + }, + ], + }, +]; + +test("OCI GenAI Generic parse invalid response", async () => { + const genericChat = new OciGenAiGenericChat(createParams); + + for (const invalidValue of invalidCGenericResponseValues) { + expect(() => genericChat._parseResponse(invalidValue)).toThrow( + "Invalid GenericChatResponse object" + ); + } +}); + +const validGenericResponseValues = [ + { + choices: [ + { + message: { + content: [ + { + type: TextContent.type, + text: "This is the response text", + }, + ], + }, + }, + ], + }, + { + choices: [ + { + message: { + content: [], + }, + }, + ], + }, + { + choices: [ + { + message: { + content: [ + { + type: TextContent.type, + text: "This is ", + }, + { + type: TextContent.type, + text: "the ", + }, + { + type: TextContent.type, + text: "response text", + }, + ], + }, + }, + ], + }, + { + choices: [ + { + message: { + content: [ + { + type: TextContent.type, + text: "This is ", + }, + ], + }, + }, + { + message: { + content: [ + { + type: TextContent.type, + text: "the response text", + }, + ], + }, + }, + ], + }, +]; + +test("OCI GenAI Generic parse valid response", async () => { + const genericChat = new OciGenAiGenericChat(createParams); + + for (const validValue of validGenericResponseValues) { + expect(["This is the response text", ""]).toContain( + genericChat._parseResponse(validValue) + ); + } +}); + +const invalidCohereStreamedChunks = [ + null, + {}, + { + ext: "this is some text", + prop: true, + }, + { + ext: "this is some text", + message: ["hello"], + }, + { + apiFormat: CohereChatRequest.apiFormat, + }, +]; + +test("OCI GenAI Cohere parse invalid streamed chunks", async () => { + const cohereChat = new OciGenAiCohereChat(createParams); + + for (const invalidValue of invalidCohereStreamedChunks) { + expect(() => cohereChat._parseStreamedResponseChunk(invalidValue)).toThrow( + "Invalid streamed response chunk data" + ); + } +}); + +const validCohereStreamedChunks = [ + { + apiFormat: CohereChatRequest.apiFormat, + text: "this is some text", + }, + { + apiFormat: CohereChatRequest.apiFormat, + text: "this is some text", + pad: "aaaaa", + }, +]; + +test("OCI GenAI Cohere parse invalid streamed chunks", async () => { + const cohereChat = new OciGenAiCohereChat(createParams); + + for (const invalidValue of validCohereStreamedChunks) { + expect(cohereChat._parseStreamedResponseChunk(invalidValue)).toBe( + "this is some text" + ); + } +}); + +const invalidGenericStreamedChunks = [ + null, + {}, + { + ext: "this is some text", + prop: true, + }, + { + ext: "this is some text", + message: ["hello"], + }, + { + apiFormat: CohereChatRequest.apiFormat, + }, +]; + +test("OCI GenAI Generic parse invalid streamed chunks", async () => { + const genericChat = new OciGenAiGenericChat(createParams); + + for (const invalidValue of invalidGenericStreamedChunks) { + expect(() => genericChat._parseStreamedResponseChunk(invalidValue)).toThrow( + "Invalid streamed response chunk data" + ); + } +}); + +const validGenericStreamedChunks = [ + { + message: { + content: [ + { + type: TextContent.type, + text: "this is some text", + }, + ], + }, + }, + { + finishReason: "stop sequence", + }, +]; + +test("OCI GenAI Generic parse invalid streamed chunks", async () => { + const genericChat = new OciGenAiGenericChat(createParams); + + for (const invalidValue of validGenericStreamedChunks) { + expect(["this is some text", undefined]).toContain( + genericChat._parseStreamedResponseChunk(invalidValue) + ); + } +}); + +test("OCI GenAI cohere history and message split", async () => { + const lastHumanMessage = "Last human message"; + + testCohereMessageHistorySplit({ + messages: [], + lastHumanMessage: "", + numExpectedMessagesInHistory: 0, + numExpectedHumanMessagesInHistory: 0, + numExpectedOtherMessagesInHistory: 0, + }); + + testCohereMessageHistorySplit({ + messages: [new LangChainHumanMessage(lastHumanMessage)], + lastHumanMessage, + numExpectedMessagesInHistory: 0, + numExpectedHumanMessagesInHistory: 0, + numExpectedOtherMessagesInHistory: 0, + }); + + testCohereMessageHistorySplit({ + messages: [ + new LangChainHumanMessage("Human message"), + new LangChainSystemMessage("System message"), + new LangChainHumanMessage("Human message"), + new LangChainSystemMessage("System message"), + new LangChainHumanMessage(lastHumanMessage), + new LangChainSystemMessage("System message"), + new LangChainSystemMessage("System message"), + ], + lastHumanMessage, + numExpectedMessagesInHistory: 6, + numExpectedHumanMessagesInHistory: 2, + numExpectedOtherMessagesInHistory: 4, + }); + + testCohereMessageHistorySplit({ + messages: [ + new LangChainHumanMessage(lastHumanMessage), + new LangChainSystemMessage("System message"), + new LangChainSystemMessage("System message"), + new LangChainSystemMessage("System message"), + new LangChainSystemMessage("System message"), + ], + lastHumanMessage, + numExpectedMessagesInHistory: 4, + numExpectedHumanMessagesInHistory: 0, + numExpectedOtherMessagesInHistory: 4, + }); + + testCohereMessageHistorySplit({ + messages: [ + new LangChainSystemMessage("System message"), + new LangChainSystemMessage("System message"), + new LangChainSystemMessage("System message"), + new LangChainSystemMessage("System message"), + new LangChainHumanMessage(lastHumanMessage), + ], + lastHumanMessage, + numExpectedMessagesInHistory: 4, + numExpectedHumanMessagesInHistory: 0, + numExpectedOtherMessagesInHistory: 4, + }); + + testCohereMessageHistorySplit({ + messages: [ + new LangChainSystemMessage("System message"), + new LangChainSystemMessage("System message"), + new LangChainSystemMessage("System message"), + new LangChainSystemMessage("System message"), + ], + lastHumanMessage: "", + numExpectedMessagesInHistory: 4, + numExpectedHumanMessagesInHistory: 0, + numExpectedOtherMessagesInHistory: 4, + }); +}); + +test("OCI GenAI chat cohere _convertBaseMessageToCohereMessage", () => { + const messageContent = "message content"; + const testCases = [ + { + message: new AIMessage(messageContent), + expectedRole: CohereChatBotMessage.role, + }, + { + message: new SystemMessage(messageContent), + expectedRole: CohereSystemMessage.role, + }, + { + message: new HumanMessage(messageContent), + expectedRole: CohereUserMessage.role, + }, + { + message: new ToolMessage(messageContent, "tool id"), + expectedError: "Message type 'tool' is not supported", + }, + ]; + + testCases.forEach((testCase) => { + if (testCase.expectedError) { + expect(() => + OciGenAiCohereChat._convertBaseMessageToCohereMessage(testCase.message) + ).toThrowError(testCase.expectedError); + } else { + expect( + OciGenAiCohereChat._convertBaseMessageToCohereMessage(testCase.message) + ).toEqual({ + role: testCase.expectedRole, + message: messageContent, + }); + } + }); +}); + +test("OCI GenAI chat generic _convertBaseMessagesToGenericMessages", () => { + const testCases = [ + { + input: [], + expectedOutput: [], + }, + { + input: [new AIMessage("Hello")], + expectedOutput: [ + { + role: GenericAssistantMessage.role, + content: [ + { + text: "Hello", + type: TextContent.type, + }, + ], + }, + ], + }, + { + input: [ + new AIMessage("Hello"), + new HumanMessage("Hi"), + new SystemMessage("Welcome"), + ], + expectedOutput: [ + { + role: GenericAssistantMessage.role, + content: [ + { + text: "Hello", + type: TextContent.type, + }, + ], + }, + { + role: GenericUserMessage.role, + content: [ + { + text: "Hi", + type: TextContent.type, + }, + ], + }, + { + role: GenericSystemMessage.role, + content: [ + { + text: "Welcome", + type: TextContent.type, + }, + ], + }, + ], + }, + { + input: [ + new AIMessage("Hello"), + new ToolMessage("Hi", "id"), + new HumanMessage("Hi"), + ], + expectedError: "Message type 'tool' is not supported", + }, + ]; + + testCases.forEach((testCase) => { + if (testCase.expectedError) { + expect(() => + OciGenAiGenericChat._convertBaseMessagesToGenericMessages( + testCase.input + ) + ).toThrow(testCase.expectedError); + } else { + expect( + OciGenAiGenericChat._convertBaseMessagesToGenericMessages( + testCase.input + ) + ).toEqual(testCase.expectedOutput); + } + }); +}); + +test("OCI GenAI chat Cohere _isCohereResponse", () => { + const testCaseArray = [ + { + input: { + text: "Hello World!", + apiFormat: "json", + }, + expectedResult: true, + }, + { + input: null, + expectedResult: false, + }, + { + input: "not an object", + expectedResult: false, + }, + { + input: 123, + expectedResult: false, + }, + { + input: undefined, + expectedResult: false, + }, + { + input: { + foo: "bar", + apiFormat: "json", + }, + expectedResult: false, + }, + { + input: { + text: 123, + apiFormat: "json", + }, + expectedResult: false, + }, + ]; + + testCaseArray.forEach(({ input, expectedResult }) => { + expect(OciGenAiCohereChat._isCohereResponse(input)).toBe(expectedResult); + }); +}); + +test("OCI GenAI chat generic _isGenericResponse", () => { + const testCases = [ + { + input: { + timeCreated: new Date(), + choices: [ + { + index: 1, + message: { + role: "assistant", + content: [{ type: "text", text: "Hello" }], + }, + finishReason: "", + }, + ], + apiFormat: "v1", + }, + expectedOutput: true, + }, + { + input: null, + expectedOutput: false, + }, + { + input: "not an object", + expectedOutput: false, + }, + { + input: { + timeCreated: new Date(), + apiFormat: "v1", + }, + expectedOutput: false, + }, + { + input: { + timeCreated: new Date(), + choices: "not an array", + apiFormat: "v1", + }, + expectedOutput: false, + }, + { + input: { + timeCreated: new Date(), + choices: [], + apiFormat: "v1", + }, + expectedOutput: true, + }, + { + input: { + timeCreated: new Date(), + choices: [ + { + index: 1, + message: "not an object", + }, + ], + apiFormat: "v1", + }, + expectedOutput: false, + }, + ]; + + testCases.forEach(({ input, expectedOutput }) => { + expect(OciGenAiGenericChat._isGenericResponse(input)).toBe(expectedOutput); + }); +}); + +test("OCI GenAI chat models invoke + check sdkClient cache logic", async () => { + await testEachChatModelType( + async (ChatClassType: OciGenAiChatConstructor, parameter) => { + const chatClass = new ChatClassType({ + compartmentId, + onDemandModelId, + client: { + chat: () => parameter, + }, + }); + + expect(OciGenAiBaseChat._isSdkClient(chatClass._sdkClient)).toBe(false); + await chatClass.invoke("this is a prompt"); + await chatClass.invoke("this is a prompt"); + expect(OciGenAiBaseChat._isSdkClient(chatClass._sdkClient)).toBe(true); + }, + chatClassReturnValues + ); +}); + +test("OCI GenAI chat models invoke API fail", async () => { + await testEachChatModelType( + async (ChatClassType: OciGenAiChatConstructor) => { + const chatClass = new ChatClassType({ + compartmentId, + onDemandModelId, + client: { + chat: () => { + throw new Error("API error"); + }, + }, + }); + + expect(OciGenAiBaseChat._isSdkClient(chatClass._sdkClient)).toBe(false); + await expect(chatClass.invoke("this is a prompt")).rejects.toThrow( + "Error executing chat API, error: API error" + ); + await expect(chatClass.invoke("this is a prompt")).rejects.toThrow( + "Error executing chat API, error: API error" + ); + expect(OciGenAiBaseChat._isSdkClient(chatClass._sdkClient)).toBe(true); + } + ); +}); + +test("OCI GenAI chat models invoke with with no initialized SDK client", async () => { + await testEachChatModelType( + async (ChatClassType: OciGenAiChatConstructor) => { + const chatClass = new ChatClassType({ + compartmentId, + dedicatedEndpointId, + client: { + chat: () => true, + }, + }); + + await expect( + chatClass._chat(chatClass._prepareRequest(messages, callOptions, true)) + ).rejects.toThrow( + "Error executing chat API, error: OCI SDK client not initialized" + ); + } + ); +}); + +test("OCI GenAI chat models invoke with sdk client uninitialized", async () => { + await testEachChatModelType( + async (ChatClassType: OciGenAiChatConstructor) => { + const chatClass = new ChatClassType({ + compartmentId, + dedicatedEndpointId, + client: { + chat: () => true, + }, + }); + + await expect( + chatClass._chat(chatClass._prepareRequest(messages, callOptions, true)) + ).rejects.toThrow( + "Error executing chat API, error: OCI SDK client not initialized" + ); + } + ); +}); + +test("OCI GenAI chat models invoke with dedicated endpoint", async () => { + await testEachChatModelType( + async (ChatClassType: OciGenAiChatConstructor, params) => { + const chatClass = new ChatClassType({ + compartmentId, + dedicatedEndpointId, + client: { + chat: () => params + }, + }); + + expect( + async () => await chatClass.invoke("this is a message") + ).not.toThrow(); + }, + chatClassReturnValues + ); +}); + +const chatStreamReturnValues: string[][] = [ + [ + `data: {"apiFormat":"${CohereChatRequest.apiFormat}", "text":"this is some text"}`, + `data: {"apiFormat":"${CohereChatRequest.apiFormat}", "text":"this is some more text"}`, + ], + [ + `data: {"message":{"content":[{"type":"${TextContent.type}","text":"this is some text"}]}}`, + `data: {"message":{"content":[{"type":"${TextContent.type}","text":"this is some more text"}]}}`, + 'data: {"finishReason":"stop sequence"}', + ], +]; + +test("OCI GenAI chat models stream", async () => { + await testEachChatModelType( + async (ChatClassType: OciGenAiChatConstructor, parameter) => { + let numApiCalls = 0; + const chatClass = new ChatClassType({ + compartmentId, + onDemandModelId, + client: { + chat: () => { + numApiCalls += 1; + return createStreamFromStringArray(parameter); + }, + }, + }); + + expect(OciGenAiBaseChat._isSdkClient(chatClass._sdkClient)).toBe(false); + let numMessages = 0; + + for await (const _message of await chatClass.stream([ + "this is a prompt", + ])) { + numMessages += 1; + } + + expect(numMessages).toBe(2); + expect(numApiCalls).toBe(1); + expect(OciGenAiBaseChat._isSdkClient(chatClass._sdkClient)).toBe(true); + }, + chatStreamReturnValues + ); +}); + +/* + * Utils + */ + +async function testInvalidValues( + streamIterator: JsonServerEventsIterator +): Promise { + let numRuns = 0; + + try { + for await (const _event of streamIterator) { + numRuns += 1; + } + } catch (error) { + expect((error)?.message).toMatch(invalidEventDataErrors); + } + + expect(numRuns).toBe(0); +} + +async function testNumExpectedServerEvents( + serverEvents: string[], + numExpectedServerEvents: number +) { + const stream = createStreamFromStringArray(serverEvents); + const streamIterator = new JsonServerEventsIterator(stream); + let numEvents = 0; + + for await (const _event of streamIterator) { + numEvents += 1; + } + + expect(numEvents).toBe(numExpectedServerEvents); +} + +function testSdkClient( + sdkClient: OciGenAiSdkClient, + regionId: string, + maxAttempts: number +) { + expect(OciGenAiBaseChat._isSdkClient(sdkClient)).toBe(true); + expect((sdkClient.client)._regionId).toBe(regionId); + expect( + (sdkClient.client)._clientConfiguration?.retryConfiguration + ?.terminationStrategy?._maxAttempts + ).toBe(maxAttempts); +} + +class StringArrayToInt8ArraySource implements UnderlyingSource { + private valuesIndex = 0; + + private textEncoder = new TextEncoder(); + + // eslint-disable-next-line no-empty-function + constructor(private values: string[]) { } + + pull(controller: ReadableStreamDefaultController) { + if (this.valuesIndex < this.values.length) { + controller.enqueue( + this.textEncoder.encode(this.values[this.valuesIndex]) + ); + this.valuesIndex += 1; + } else { + controller.close(); + } + } + + cancel() { + this.valuesIndex = this.values.length; + } +} + +function createStreamFromStringArray( + values: string[] +): ReadableStream { + return new ReadableStream(new StringArrayToInt8ArraySource(values)); +} + +async function testEachChatModelType( + testFunction: ( + ChatClassType: OciGenAiChatConstructor, + parameter?: any | undefined + ) => Promise, + parameters?: any[] +) { + const chatClassTypes: OciGenAiChatConstructor[] = [ + OciGenAiCohereChat, + OciGenAiGenericChat, + ]; + + for (let i = 0; i < chatClassTypes.length; i += 1) { + await testFunction(chatClassTypes[i], parameters?.at(i)); + } +} + +interface TestMessageHistorySplitParams { + messages: BaseMessage[]; + lastHumanMessage: string; + numExpectedMessagesInHistory: number; + numExpectedHumanMessagesInHistory: number; + numExpectedOtherMessagesInHistory: number; +} + +function testCohereMessageHistorySplit(params: TestMessageHistorySplitParams) { + const messageAndHistory = OciGenAiCohereChat._splitMessageAndHistory( + params.messages + ); + + expect(messageAndHistory.message).toBe(params.lastHumanMessage); + expect(messageAndHistory.chatHistory.length).toBe( + params.numExpectedMessagesInHistory + ); + + let numHumanMessages = params.numExpectedHumanMessagesInHistory; + let numOtherMessages = params.numExpectedOtherMessagesInHistory; + + for (const message of messageAndHistory.chatHistory) { + testCohereMessageHistorySplitMessage(message, params.lastHumanMessage); + + if (message.role === OciGenAiCohereUserMessage.role) { + numHumanMessages -= 1; + } else { + numOtherMessages -= 1; + } + } + + expect(numHumanMessages).toBe(0); + expect(numOtherMessages).toBe(0); +} + +function testCohereMessageHistorySplitMessage( + message: CohereMessage, + lastHumanMessage: string +) { + expect([ + OciGenAiCohereSystemMessage.role, + OciGenAiCohereUserMessage.role, + ]).toContain(message.role); + expect((message).message).not.toBe(lastHumanMessage); +} + +function removeElements(originalArray: any[], removeIndexes: number[]): any[] { + for (const removeIndex of removeIndexes) { + originalArray.splice(removeIndex, 1); + } + + return originalArray; +} \ No newline at end of file diff --git a/libs/langchain-oci-genai/src/types.ts b/libs/langchain-oci-genai/src/types.ts new file mode 100644 index 000000000000..4509b4d69c23 --- /dev/null +++ b/libs/langchain-oci-genai/src/types.ts @@ -0,0 +1,65 @@ +import { + BaseChatModelCallOptions, + BaseChatModelParams, +} from "@langchain/core/language_models/chat_models"; +import { AuthParams, ClientConfiguration } from "oci-common"; +import { GenerativeAiInferenceClient } from "oci-generativeaiinference"; + +import { + ChatDetails, + CohereChatRequest, + CohereChatResponse, + GenericChatRequest, + GenericChatResponse, +} from "oci-generativeaiinference/lib/model"; + +import { ChatResponse } from "oci-generativeaiinference/lib/response"; + +export enum OciGenAiNewClientAuthType { + ConfigFile, + InstancePrincipal, + Other, +} + +export interface ConfigFileAuthParams { + clientConfigFilePath: string; + clientProfile: string; +} + +export interface OciGenAiNewClientParams { + authType: OciGenAiNewClientAuthType; + regionId?: string; + authParams?: ConfigFileAuthParams | AuthParams; + clientConfiguration?: ClientConfiguration; +} + +export interface OciGenAiClientParams { + client?: GenerativeAiInferenceClient; + newClientParams?: OciGenAiNewClientParams; +} + +export interface OciGenAiServingParams { + onDemandModelId?: string; + dedicatedEndpointId?: string; +} + +export type OciGenAiSupportedRequestType = + | GenericChatRequest + | CohereChatRequest; +export type OciGenAiModelBaseParams = BaseChatModelParams & + OciGenAiClientParams & + Omit & + OciGenAiServingParams; + +export interface OciGenAiModelCallOptions + extends BaseChatModelCallOptions { + requestParams?: RequestType; +} + +export type OciGenAiSupportedResponseType = + | GenericChatResponse + | CohereChatResponse; +export type OciGenAiChatCallResponseType = + | ChatResponse + | ReadableStream + | null; \ No newline at end of file diff --git a/libs/langchain-oci-genai/tsconfig.cjs.json b/libs/langchain-oci-genai/tsconfig.cjs.json new file mode 100644 index 000000000000..3b7026ea406c --- /dev/null +++ b/libs/langchain-oci-genai/tsconfig.cjs.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "declaration": false + }, + "exclude": ["node_modules", "dist", "docs", "**/tests"] +} diff --git a/libs/langchain-oci-genai/tsconfig.json b/libs/langchain-oci-genai/tsconfig.json new file mode 100644 index 000000000000..bc85d83b6229 --- /dev/null +++ b/libs/langchain-oci-genai/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "@tsconfig/recommended", + "compilerOptions": { + "outDir": "../dist", + "rootDir": "./src", + "target": "ES2021", + "lib": ["ES2021", "ES2022.Object", "DOM"], + "module": "ES2020", + "moduleResolution": "nodenext", + "esModuleInterop": true, + "declaration": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "useDefineForClassFields": true, + "strictPropertyInitialization": false, + "allowJs": true, + "strict": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "docs"] +} diff --git a/libs/langchain-oci-genai/turbo.json b/libs/langchain-oci-genai/turbo.json new file mode 100644 index 000000000000..d024cee15c81 --- /dev/null +++ b/libs/langchain-oci-genai/turbo.json @@ -0,0 +1,11 @@ +{ + "extends": ["//"], + "pipeline": { + "build": { + "outputs": ["**/dist/**"] + }, + "build:internal": { + "dependsOn": ["^build:internal"] + } + } +}