From d3f5d1cbdf9612f4ce10434adc85d4eda196c1f8 Mon Sep 17 00:00:00 2001 From: Tom Frenken <54979414+tomfrenken@users.noreply.github.com> Date: Thu, 19 Sep 2024 19:11:15 +0200 Subject: [PATCH] feat: Add Langchain integration (#120) --- README.md | 11 + eslint.config.js | 6 + package.json | 1 + packages/langchain/README.md | 121 ++++++++ packages/langchain/internal.d.ts | 3 + packages/langchain/internal.js | 2 + packages/langchain/jest.config.mjs | 5 + packages/langchain/package.json | 38 +++ packages/langchain/src/index.ts | 9 + packages/langchain/src/internal.ts | 1 + .../openai/__snapshots__/util.test.ts.snap | 50 ++++ packages/langchain/src/openai/chat.ts | 80 ++++++ packages/langchain/src/openai/embedding.ts | 49 ++++ packages/langchain/src/openai/index.ts | 4 + packages/langchain/src/openai/types.ts | 44 +++ packages/langchain/src/openai/util.test.ts | 56 ++++ packages/langchain/src/openai/util.ts | 264 ++++++++++++++++++ packages/langchain/tsconfig.cjs.json | 7 + packages/langchain/tsconfig.json | 12 + pnpm-lock.yaml | 264 ++++++++++++++++++ pnpm-workspace.yaml | 1 + sample-code/package.json | 3 + sample-code/src/index.ts | 7 + sample-code/src/langchain-azure-openai.ts | 56 ++++ sample-code/src/server.ts | 67 ++++- test-util/mock-http.ts | 14 +- tests/e2e-tests/src/foundation-models.test.ts | 2 - tests/e2e-tests/src/open-ai-langchain.test.ts | 36 +++ 28 files changed, 1200 insertions(+), 13 deletions(-) create mode 100644 packages/langchain/README.md create mode 100644 packages/langchain/internal.d.ts create mode 100644 packages/langchain/internal.js create mode 100644 packages/langchain/jest.config.mjs create mode 100644 packages/langchain/package.json create mode 100644 packages/langchain/src/index.ts create mode 100644 packages/langchain/src/internal.ts create mode 100644 packages/langchain/src/openai/__snapshots__/util.test.ts.snap create mode 100644 packages/langchain/src/openai/chat.ts create mode 100644 packages/langchain/src/openai/embedding.ts create mode 100644 packages/langchain/src/openai/index.ts create mode 100644 packages/langchain/src/openai/types.ts create mode 100644 packages/langchain/src/openai/util.test.ts create mode 100644 packages/langchain/src/openai/util.ts create mode 100644 packages/langchain/tsconfig.cjs.json create mode 100644 packages/langchain/tsconfig.json create mode 100644 sample-code/src/langchain-azure-openai.ts create mode 100644 tests/e2e-tests/src/open-ai-langchain.test.ts diff --git a/README.md b/README.md index bba78674..11ec50b9 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Integrate chat completion into your business applications with SAP Cloud SDK for - [@sap-ai-sdk/ai-api](#sap-ai-sdkai-api) - [@sap-ai-sdk/foundation-models](#sap-ai-sdkfoundation-models) - [@sap-ai-sdk/orchestration](#sap-ai-sdkorchestration) + - [@sap-ai-sdk/langchain](#sap-ai-sdklangchain) - [SAP Cloud SDK for AI Sample Project](#sap-cloud-sdk-for-ai-sample-project) - [Support, Feedback, Contribution](#support-feedback-contribution) - [Security / Disclosure](#security--disclosure) @@ -56,6 +57,16 @@ This package incorporates generative AI foundation models into your AI activitie $ npm install @sap-ai-sdk/foundation-models ``` +### @sap-ai-sdk/langchain + +This package provides LangChain model clients, built on top of the foundation model clients of the SAP Cloud SDK for AI. + +#### Installation + +``` +$ npm install @sap-ai-sdk/langchain +``` + ## SAP Cloud SDK for AI Sample Project We have created a sample project demonstrating the different clients' usage of the SAP Cloud SDK for AI for TypeScript/JavaScript. The [project README](./sample-code/README.md) outlines the set-up needed to build and run it locally. diff --git a/eslint.config.js b/eslint.config.js index 5b0bbc3d..c258e2f0 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -34,6 +34,12 @@ export default [ 'jsdoc/require-jsdoc': 'off' } }, + { + files: ['packages/langchain/**/*.ts'], + rules: { + 'import/no-internal-modules': 'off' + } + }, { files: ['packages/foundation-models/src/azure-openai/client/inference/schema/*.ts'], rules: { diff --git a/package.json b/package.json index 379ae309..c04fa673 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "foundation-models": "pnpm -F=@sap-ai-sdk/foundation-models", "orchestration": "pnpm -F=@sap-ai-sdk/orchestration", "core": "pnpm -F=@sap-ai-sdk/core", + "langchain": "pnpm -F=@sap-ai-sdk/langchain", "e2e-tests": "pnpm -F=@sap-ai-sdk/e2e-tests", "type-tests": "pnpm -F=@sap-ai-sdk/type-tests", "smoke-tests": "pnpm -F=@sap-ai-sdk/smoke-tests", diff --git a/packages/langchain/README.md b/packages/langchain/README.md new file mode 100644 index 00000000..331a5337 --- /dev/null +++ b/packages/langchain/README.md @@ -0,0 +1,121 @@ +# @sap-ai-sdk/langchain + +This package provides LangChain model clients, built on top of the foundation model clients of the SAP Cloud SDK for AI. + +## Table of Contents + +1. [Installation](#installation) +2. [Pre-requisites](#pre-requisites) +3. [Usage](#usage) + - [Client Initialization](#client-initialization) + - [Chat Clients](#chat-clients) + - [Embedding Clients](#embedding-clients) +4. [Support, Feedback, Contribution](#support-feedback-contribution) +5. [License](#license) + +## Installation + +``` +$ npm install @sap-ai-sdk/langchain +``` + +## Pre-requisites + +- [Enable the AI Core service in SAP BTP](https://help.sap.com/docs/sap-ai-core/sap-ai-core-service-guide/initial-setup). +- Bind the service to your application. +- Ensure the project is configured with Node.js v20 or higher, along with native ESM support. +- For testing your application locally: + - Download a service key for your AI Core service instance. + - Create a `.env` file in the root of your directory. + - Add an entry `AICORE_SERVICE_KEY=''`. + +## Usage + +This package provides both chat and embedding clients, currently supporting Azure OpenAI. +All clients comply with [LangChain's interface](https://js.langchain.com/docs/introduction). + +### Client Initialization + +To initialize a client, provide the model name: + +```ts +import { + AzureOpenAiChatClient, + AzureOpenAiEmbeddingClient +} from '@sap-ai-sdk/langchain'; + +// For a chat client +const chatClient = new AzureOpenAiChatClient({ modelName: 'gpt-4o' }); +// For an embedding client +const embeddingClient = new AzureOpenAiEmbeddingClient({ modelName: 'gpt-4o' }); +``` + +In addition to the default parameters of the model vendor (e.g. OpenAI) and LangChain, there are additional parameters, which you can use to narrow down the search for the model you want to use: + +```ts +const chatClient = new AzureOpenAiChatClient({ + modelName: 'gpt-4o', + modelVersion: '24-07-2021', + resourceGroup: 'my-resource-group' +}); +``` + +### Chat Client + +The chat clients allow you to interact with Azure OpenAI chat models, accessible via the generative AI hub of SAP AI Core. +To invoke the client, you only have a to pass a prompt: + +```ts +const response = await chatClient.invoke("What's the capital of France?"); +``` + +#### Advanced Example with Templating and Output Parsing + +```ts +import { AzureOpenAiChatClient } from '@sap-ai-sdk/langchain'; +import { StringOutputParser } from '@langchain/core/output_parsers'; +import { ChatPromptTemplate } from '@langchain/core/prompts'; + +const client = new AzureOpenAiChatClient({ modelName: 'gpt-35-turbo' }); +const promptTemplate = ChatPromptTemplate.fromMessages([ + ['system', 'Answer the following in {language}:'], + ['user', '{text}'] +]); +const parser = new StringOutputParser(); +const llmChain = promptTemplate.pipe(client).pipe(parser); +const response = await llmChain.invoke({ + language: 'german', + text: 'What is the capital of France?' +}); +``` + +### Embedding Client + +Embedding clients allow embedding either text or documents (represented as arrays of strings). + +#### Embed Text + +```ts +const embeddedText = await embeddingClient.embedQuery( + 'Paris is the capital of France.' +); +``` + +#### Embed Documents + +```ts +const embeddedDocument = await embeddingClient.embedDocuments([ + 'Page 1: Paris is the capital of France.', + 'Page 2: It is a beautiful city.' +]); +``` + +## Support, Feedback, Contribution + +This project is open to feature requests/suggestions, bug reports etc. via [GitHub issues](https://github.com/SAP/ai-sdk-js/issues). + +Contribution and feedback are encouraged and always welcome. For more information about how to contribute, the project structure, as well as additional contribution information, see our [Contribution Guidelines](https://github.com/SAP/ai-sdk-js/blob/main/CONTRIBUTING.md). + +## License + +The SAP Cloud SDK for AI is released under the [Apache License Version 2.0.](http://www.apache.org/licenses/). diff --git a/packages/langchain/internal.d.ts b/packages/langchain/internal.d.ts new file mode 100644 index 00000000..bf1fe07d --- /dev/null +++ b/packages/langchain/internal.d.ts @@ -0,0 +1,3 @@ +// eslint-disable-next-line import/no-internal-modules +export * from './dist/internal.js'; +// # sourceMappingURL=internal.d.ts.map diff --git a/packages/langchain/internal.js b/packages/langchain/internal.js new file mode 100644 index 00000000..0c80210d --- /dev/null +++ b/packages/langchain/internal.js @@ -0,0 +1,2 @@ +export * from './dist/internal.js'; +//# sourceMappingURL=internal.js.map diff --git a/packages/langchain/jest.config.mjs b/packages/langchain/jest.config.mjs new file mode 100644 index 00000000..b09fd097 --- /dev/null +++ b/packages/langchain/jest.config.mjs @@ -0,0 +1,5 @@ +import config from '../../jest.config.mjs'; +export default { + ...config, + displayName: 'langchain', +}; diff --git a/packages/langchain/package.json b/packages/langchain/package.json new file mode 100644 index 00000000..c955be7b --- /dev/null +++ b/packages/langchain/package.json @@ -0,0 +1,38 @@ +{ + "name": "@sap-ai-sdk/langchain", + "version": "0.1.0", + "description": "LangChain clients based on the @sap-ai-sdk", + "license": "Apache-2.0", + "keywords": [ + "sap-ai-sdk", + "langchain" + ], + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist/**/*.js", + "dist/**/*.js.map", + "dist/**/*.d.ts", + "dist/**/*.d.ts.map", + "internal.js", + "internal.d.ts" + ], + "scripts": { + "compile": "tsc", + "compile:cjs": "tsc -p tsconfig.cjs.json", + "test": "NODE_OPTIONS=--experimental-vm-modules jest", + "lint": "eslint \"**/*.ts\" && prettier . --config ../../.prettierrc --ignore-path ../../.prettierignore -c", + "lint:fix": "eslint \"**/*.ts\" --fix && prettier . --config ../../.prettierrc --ignore-path ../../.prettierignore -w --log-level error", + "check:public-api": "node --loader ts-node/esm ../../scripts/check-public-api-cli.ts" + }, + "dependencies": { + "@sap-ai-sdk/ai-api": "workspace:^", + "@sap-ai-sdk/foundation-models": "workspace:^", + "@langchain/core": "0.3.1", + "zod-to-json-schema": "^3.23.2" + }, + "devDependencies": { + "typescript": "^5.5.4" + } +} diff --git a/packages/langchain/src/index.ts b/packages/langchain/src/index.ts new file mode 100644 index 00000000..5e1fcb6c --- /dev/null +++ b/packages/langchain/src/index.ts @@ -0,0 +1,9 @@ +export { + AzureOpenAiChatClient, + AzureOpenAiEmbeddingClient +} from './openai/index.js'; +export type { + AzureOpenAiChatModelParams, + AzureOpenAiEmbeddingModelParams, + AzureOpenAiChatCallOptions +} from './openai/index.js'; diff --git a/packages/langchain/src/internal.ts b/packages/langchain/src/internal.ts new file mode 100644 index 00000000..06718ab5 --- /dev/null +++ b/packages/langchain/src/internal.ts @@ -0,0 +1 @@ +export * from './openai/index.js'; diff --git a/packages/langchain/src/openai/__snapshots__/util.test.ts.snap b/packages/langchain/src/openai/__snapshots__/util.test.ts.snap new file mode 100644 index 00000000..09fa9e71 --- /dev/null +++ b/packages/langchain/src/openai/__snapshots__/util.test.ts.snap @@ -0,0 +1,50 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Mapping Functions should parse an OpenAI response to a (LangChain) chat response 1`] = ` +{ + "generations": [ + { + "generationInfo": { + "finish_reason": "stop", + "function_call": undefined, + "index": 0, + "tool_calls": undefined, + }, + "message": { + "id": [ + "langchain_core", + "messages", + "AIMessage", + ], + "kwargs": { + "additional_kwargs": { + "finish_reason": "stop", + "function_call": undefined, + "index": 0, + "tool_call_id": "", + "tool_calls": undefined, + }, + "content": "The deepest place on Earth is located in the Western Pacific Ocean and is known as the Mariana Trench.", + "invalid_tool_calls": [], + "response_metadata": {}, + "tool_calls": [], + }, + "lc": 1, + "type": "constructor", + }, + "text": "The deepest place on Earth is located in the Western Pacific Ocean and is known as the Mariana Trench.", + }, + ], + "llmOutput": { + "created": 1725457796, + "id": "chatcmpl-A3kgOwg9B6j87n0IkoCFCUCxRSwQZ", + "model": "gpt-4-32k", + "object": "chat.completion", + "tokenUsage": { + "completionTokens": 22, + "promptTokens": 15, + "totalTokens": 37, + }, + }, +} +`; diff --git a/packages/langchain/src/openai/chat.ts b/packages/langchain/src/openai/chat.ts new file mode 100644 index 00000000..6fb7a6e3 --- /dev/null +++ b/packages/langchain/src/openai/chat.ts @@ -0,0 +1,80 @@ +import { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager'; +import { BaseMessage } from '@langchain/core/messages'; +import type { ChatResult } from '@langchain/core/outputs'; +import { AzureOpenAiChatClient as AzureOpenAiChatClientBase } from '@sap-ai-sdk/foundation-models'; +import { BaseChatModel } from '@langchain/core/language_models/chat_models'; +import { AzureOpenAiChatModel } from '@sap-ai-sdk/core'; +import { mapLangchainToAiClient, mapOutputToChatResult } from './util.js'; +import type { + AzureOpenAiChatCallOptions, + AzureOpenAiChatModelParams +} from './types.js'; + +/** + * LangChain chat client for Azure OpenAI consumption on SAP BTP. + */ +export class AzureOpenAiChatClient + extends BaseChatModel + implements AzureOpenAiChatModelParams +{ + modelName: AzureOpenAiChatModel; + modelVersion?: string; + resourceGroup?: string; + temperature?: number; + top_p?: number; + logit_bias?: Record; + user?: string; + n?: number; + presence_penalty?: number; + frequency_penalty?: number; + stop?: string | string[]; + max_tokens?: number; + private openAiChatClient: AzureOpenAiChatClientBase; + + constructor(fields: AzureOpenAiChatModelParams) { + super(fields); + this.openAiChatClient = new AzureOpenAiChatClientBase(fields); + this.modelName = fields.modelName; + this.modelVersion = fields.modelVersion; + this.resourceGroup = fields.resourceGroup; + this.temperature = fields.temperature; + this.top_p = fields.top_p; + this.logit_bias = fields.logit_bias; + this.user = fields.user; + this.n = fields.n; + this.stop = fields.stop; + this.presence_penalty = fields.presence_penalty; + this.frequency_penalty = fields.frequency_penalty; + this.max_tokens = fields.max_tokens; + } + + _llmType(): string { + return 'azure_openai'; + } + + override async _generate( + messages: BaseMessage[], + options: typeof this.ParsedCallOptions, + runManager?: CallbackManagerForLLMRun + ): Promise { + const res = await this.caller.callWithOptions( + { + signal: options.signal + }, + () => + this.openAiChatClient.run( + mapLangchainToAiClient(this, options, messages), + options.requestConfig + ) + ); + + const content = res.getContent(); + + // we currently do not support streaming + await runManager?.handleLLMNewToken( + typeof content === 'string' ? content : '' + ); + + return mapOutputToChatResult(res.data); + } +} diff --git a/packages/langchain/src/openai/embedding.ts b/packages/langchain/src/openai/embedding.ts new file mode 100644 index 00000000..c82472aa --- /dev/null +++ b/packages/langchain/src/openai/embedding.ts @@ -0,0 +1,49 @@ +import { + AzureOpenAiEmbeddingClient as AzureOpenAiEmbeddingClientBase, + AzureOpenAiEmbeddingParameters +} from '@sap-ai-sdk/foundation-models'; +import { Embeddings } from '@langchain/core/embeddings'; +import { AzureOpenAiChatModel } from '@sap-ai-sdk/core'; +import { AzureOpenAiEmbeddingModelParams } from './types.js'; + +/** + * LangChain embedding client for Azure OpenAI consumption on SAP BTP. + */ +export class AzureOpenAiEmbeddingClient + extends Embeddings + implements AzureOpenAiEmbeddingModelParams +{ + modelName: AzureOpenAiChatModel; + modelVersion?: string; + resourceGroup?: string; + + private openAiEmbeddingClient: AzureOpenAiEmbeddingClientBase; + + constructor(fields: AzureOpenAiEmbeddingModelParams) { + super(fields); + this.openAiEmbeddingClient = new AzureOpenAiEmbeddingClientBase(fields); + this.modelName = fields.modelName; + this.modelVersion = fields.modelVersion; + this.resourceGroup = fields.resourceGroup; + } + + override async embedDocuments(documents: string[]): Promise { + return Promise.all( + documents.map(document => this.createEmbedding({ input: document })) + ); + } + + override async embedQuery(input: string): Promise { + return this.createEmbedding({ input }); + } + + private async createEmbedding( + query: AzureOpenAiEmbeddingParameters + ): Promise { + return this.caller.callWithOptions( + {}, + async () => + (await this.openAiEmbeddingClient.run(query)).getEmbedding() ?? [] + ); + } +} diff --git a/packages/langchain/src/openai/index.ts b/packages/langchain/src/openai/index.ts new file mode 100644 index 00000000..56f5dcd9 --- /dev/null +++ b/packages/langchain/src/openai/index.ts @@ -0,0 +1,4 @@ +export * from './chat.js'; +export * from './embedding.js'; +export * from './types.js'; +export * from './util.js'; diff --git a/packages/langchain/src/openai/types.ts b/packages/langchain/src/openai/types.ts new file mode 100644 index 00000000..6fec0ece --- /dev/null +++ b/packages/langchain/src/openai/types.ts @@ -0,0 +1,44 @@ +import type { + BaseChatModelCallOptions, + BaseChatModelParams +} from '@langchain/core/language_models/chat_models'; +import { BaseLLMParams } from '@langchain/core/language_models/llms'; +import type { AzureOpenAiCreateChatCompletionRequest } from '@sap-ai-sdk/foundation-models'; +import type { + AzureOpenAiChatModel, + CustomRequestConfig +} from '@sap-ai-sdk/core'; +import type { ModelConfig, ResourceGroupConfig } from '@sap-ai-sdk/ai-api'; + +/** + * Input type for {@link AzureOpenAiChatClient} initialization. + */ +export type AzureOpenAiChatModelParams = Omit< + AzureOpenAiCreateChatCompletionRequest, + | 'messages' + | 'response_format' + | 'seed' + | 'functions' + | 'tools' + | 'tool_choice' +> & + BaseChatModelParams & + ModelConfig & + ResourceGroupConfig; + +/** + * Call options for the {@link AzureOpenAiChatClient}. + */ +export type AzureOpenAiChatCallOptions = BaseChatModelCallOptions & + Pick< + AzureOpenAiCreateChatCompletionRequest, + 'response_format' | 'seed' | 'functions' | 'tools' | 'tool_choice' + > & { + requestConfig?: CustomRequestConfig; + }; + +/** + * Input type for {@link AzureOpenAiEmbeddingClient} initialization. + */ +export type AzureOpenAiEmbeddingModelParams = + ModelConfig & ResourceGroupConfig & BaseLLMParams; diff --git a/packages/langchain/src/openai/util.test.ts b/packages/langchain/src/openai/util.test.ts new file mode 100644 index 00000000..efeaa6e6 --- /dev/null +++ b/packages/langchain/src/openai/util.test.ts @@ -0,0 +1,56 @@ +import { + AzureOpenAiCreateChatCompletionResponse, + AzureOpenAiCreateChatCompletionRequest +} from '@sap-ai-sdk/foundation-models'; +import nock from 'nock'; +import { BaseMessage, HumanMessage } from '@langchain/core/messages'; +import { + mockClientCredentialsGrantCall, + parseMockResponse +} from '../../../../test-util/mock-http.js'; +import { mapLangchainToAiClient, mapOutputToChatResult } from './util.js'; +import { AzureOpenAiChatClient } from './chat.js'; + +const openAiMockResponse = + parseMockResponse( + 'foundation-models', + 'azure-openai-chat-completion-success-response.json' + ); + +describe('Mapping Functions', () => { + beforeEach(() => { + mockClientCredentialsGrantCall(); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + it('should parse an OpenAI response to a (LangChain) chat response', async () => { + const result = mapOutputToChatResult(openAiMockResponse); + expect(result).toMatchSnapshot(); + }); + + it('should parse a LangChain input to an AI SDK input', async () => { + const langchainPrompt: BaseMessage[] = [ + new HumanMessage('Where is the deepest place on earth located') + ]; + + const request: AzureOpenAiCreateChatCompletionRequest = { + messages: [ + { + role: 'user', + content: 'Where is the deepest place on earth located' + } + ] + }; + const client = new AzureOpenAiChatClient({ modelName: 'gpt-35-turbo' }); + const defaultOptions = { signal: undefined, promptIndex: 0 }; + const mapping = mapLangchainToAiClient( + client, + defaultOptions, + langchainPrompt + ); + expect(mapping).toMatchObject(request); + }); +}); diff --git a/packages/langchain/src/openai/util.ts b/packages/langchain/src/openai/util.ts new file mode 100644 index 00000000..a0eabe22 --- /dev/null +++ b/packages/langchain/src/openai/util.ts @@ -0,0 +1,264 @@ +import { AIMessage, BaseMessage, ToolMessage } from '@langchain/core/messages'; +import { ChatResult } from '@langchain/core/outputs'; +import { StructuredTool } from '@langchain/core/tools'; +import { + type AzureOpenAiChatCompletionTool, + type AzureOpenAiChatCompletionRequestMessage, + type AzureOpenAiCreateChatCompletionResponse, + type AzureOpenAiCreateChatCompletionRequest, + type AzureOpenAiChatCompletionFunctionParameters, + AzureOpenAiChatCompletionRequestMessageSystem, + AzureOpenAiChatCompletionRequestMessageUser, + AzureOpenAiChatCompletionRequestMessageAssistant, + AzureOpenAiChatCompletionRequestMessageTool, + AzureOpenAiChatCompletionRequestMessageFunction +} from '@sap-ai-sdk/foundation-models'; +import { zodToJsonSchema } from 'zod-to-json-schema'; +import { AzureOpenAiChatClient } from './chat.js'; +import { AzureOpenAiChatCallOptions } from './types.js'; + +type ToolChoice = + | 'none' + | 'auto' + | { + /** + * The type of the tool. + */ + type: 'function'; + /** + * Use to force the model to call a specific function. + */ + function: { + /** + * The name of the function to call. + */ + name: string; + }; + }; + +type LangChainToolChoice = string | Record | 'auto' | 'any'; + +/** + * Maps a LangChain {@link StructuredTool} to {@link AzureOpenAiChatCompletionFunction}. + * @param tool - Base class for tools that accept input of any shape defined by a Zod schema. + * @returns The OpenAI chat completion function. + */ +function mapToolToOpenAiFunction(tool: StructuredTool): { + description?: string; + name: string; + parameters: AzureOpenAiChatCompletionFunctionParameters; +} & Record { + return { + name: tool.name, + description: tool.description, + parameters: zodToJsonSchema(tool.schema) + }; +} + +/** + * Maps a LangChain {@link StructuredTool} to {@link AzureOpenAiChatCompletionTool}. + * @param tool - Base class for tools that accept input of any shape defined by a Zod schema. + * @returns The OpenAI chat completion tool. + */ +function mapToolToOpenAiTool( + tool: StructuredTool +): AzureOpenAiChatCompletionTool { + return { + type: 'function', + function: mapToolToOpenAiFunction(tool) + }; +} + +/** + * Maps a {@link BaseMessage} to{@link AzureOpenAiChatMessage} message role. + * @param message - The {@link BaseMessage} to map. + * @returns The {@link AzureOpenAiChatMessage} message Role. + */ +function mapBaseMessageToRole( + message: BaseMessage +): AzureOpenAiChatCompletionRequestMessage['role'] { + const messageTypeToRoleMap = new Map< + string, + AzureOpenAiChatCompletionRequestMessage['role'] + >([ + ['human', 'user'], + ['ai', 'assistant'], + ['system', 'system'], + ['function', 'function'], + ['tool', 'tool'] + ]); + + const messageType = message._getType(); + const role = messageTypeToRoleMap.get(messageType); + if (!role) { + throw new Error(`Unsupported message type: ${messageType}`); + } + return role; +} + +/** + * Maps {@link AzureOpenAiCreateChatCompletionResponse} to LangChain's {@link ChatResult}. + * @param completionResponse - The {@link AzureOpenAiCreateChatCompletionResponse} response. + * @returns The LangChain {@link ChatResult} + * @internal + */ +export function mapOutputToChatResult( + completionResponse: AzureOpenAiCreateChatCompletionResponse +): ChatResult { + return { + generations: completionResponse.choices.map( + (choice: (typeof completionResponse)['choices'][0]) => ({ + text: choice.message?.content || '', + message: new AIMessage({ + content: choice.message?.content || '', + additional_kwargs: { + finish_reason: choice.finish_reason, + index: choice.index, + function_call: choice.message?.function_call, + tool_calls: choice.message?.tool_calls, + tool_call_id: '' + } + }), + generationInfo: { + finish_reason: choice.finish_reason, + index: choice.index, + function_call: choice.message?.function_call, + tool_calls: choice.message?.tool_calls + } + }) + ), + llmOutput: { + created: completionResponse.created, + id: completionResponse.id, + model: completionResponse.model, + object: completionResponse.object, + tokenUsage: { + completionTokens: completionResponse.usage?.completion_tokens || 0, + promptTokens: completionResponse.usage?.prompt_tokens || 0, + totalTokens: completionResponse.usage?.total_tokens || 0 + } + } + }; +} + +/** + * Maps {@link BaseMessage} to {@link AzureOpenAiChatMessage}. + * @param message - The message to map. + * @returns The {@link AzureOpenAiChatMessage}. + */ +function mapBaseMessageToAzureOpenAiChatMessage( + message: BaseMessage +): AzureOpenAiChatCompletionRequestMessage { + return removeUndefinedProperties({ + name: message.name, + ...mapRoleAndContent(message), + function_call: message.additional_kwargs.function_call, + tool_calls: message.additional_kwargs.tool_calls, + tool_call_id: mapToolCallId(message) + }); +} + +// The following types are used to match a role to its specific content, otherwise TypeScript would not be able to infer the content type. + +type Role = 'system' | 'user' | 'assistant' | 'tool' | 'function'; + +type ContentType = T extends 'system' + ? AzureOpenAiChatCompletionRequestMessageSystem['content'] + : T extends 'user' + ? AzureOpenAiChatCompletionRequestMessageUser['content'] + : T extends 'assistant' + ? AzureOpenAiChatCompletionRequestMessageAssistant['content'] + : T extends 'tool' + ? AzureOpenAiChatCompletionRequestMessageTool['content'] + : T extends 'function' + ? AzureOpenAiChatCompletionRequestMessageFunction['content'] + : never; + +type RoleAndContent = { + [T in Role]: { role: T; content: ContentType }; +}[Role]; + +function mapRoleAndContent(baseMessage: BaseMessage): RoleAndContent { + const role = mapBaseMessageToRole(baseMessage); + if (!['system', 'user', 'assistant', 'tool', 'function'].includes(role)) { + throw new Error(`Unsupported message role: ${role}`); + } + return { + role, + content: baseMessage.content as ContentType + } as RoleAndContent; +} + +function isStructuredToolArray(tools?: unknown[]): tools is StructuredTool[] { + return !!tools?.every(tool => + Array.isArray((tool as StructuredTool).lc_namespace) + ); +} + +/** + * Has to return an empty string to match one of the types of {@link AzureOpenAiChatCompletionRequestMessage}. + * @internal + */ +function mapToolCallId(message: BaseMessage): string { + return message._getType() === 'tool' + ? (message as ToolMessage).tool_call_id + : ''; +} + +function mapToolChoice( + toolChoice?: LangChainToolChoice +): ToolChoice | undefined { + if (toolChoice === 'auto' || toolChoice === 'none') { + return toolChoice; + } + + if (typeof toolChoice === 'string') { + return { + type: 'function', + function: { name: toolChoice } + }; + } +} + +/** + * Maps LangChain's input interface to the AI SDK client's input interface + * @param client The LangChain Azure OpenAI client + * @param options The {@link AzureOpenAiChatCallOptions} + * @param messages The messages to be send + * @returns An AI SDK compatibile request + * @internal + */ +export function mapLangchainToAiClient( + client: AzureOpenAiChatClient, + options: AzureOpenAiChatCallOptions & { promptIndex?: number }, + messages: BaseMessage[] +): AzureOpenAiCreateChatCompletionRequest { + return removeUndefinedProperties({ + messages: messages.map(mapBaseMessageToAzureOpenAiChatMessage), + max_tokens: client.max_tokens === -1 ? undefined : client.max_tokens, + temperature: client.temperature, + top_p: client.top_p, + logit_bias: client.logit_bias, + n: client.n, + stop: options?.stop ?? client.stop, + functions: isStructuredToolArray(options?.functions) + ? options?.functions.map(mapToolToOpenAiFunction) + : options?.functions, + tools: isStructuredToolArray(options?.tools) + ? options?.tools.map(mapToolToOpenAiTool) + : options?.tools, + tool_choice: mapToolChoice(options?.tool_choice), + response_format: options?.response_format, + seed: options?.seed + }); +} + +function removeUndefinedProperties(obj: T): T { + const result = { ...obj }; + for (const key in result) { + if (result[key as keyof T] === undefined) { + delete result[key as keyof T]; + } + } + return result; +} diff --git a/packages/langchain/tsconfig.cjs.json b/packages/langchain/tsconfig.cjs.json new file mode 100644 index 00000000..a302e446 --- /dev/null +++ b/packages/langchain/tsconfig.cjs.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "CommonJS", + "outDir": "./dist-cjs" + } +} diff --git a/packages/langchain/tsconfig.json b/packages/langchain/tsconfig.json new file mode 100644 index 00000000..c4babcce --- /dev/null +++ b/packages/langchain/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "tsBuildInfoFile": "./dist/.tsbuildinfo", + "composite": true + }, + "include": ["src/**/*.ts"], + "exclude": ["dist/**/*", "test/**/*", "**/*.test.ts", "node_modules/**/*"], + "references": [{ "path": "../foundation-models" }, { "path": "../ai-api" }] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef20e36e..4ce26dfd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -152,6 +152,25 @@ importers: specifier: ^3.23.8 version: 3.23.8 + packages/langchain: + dependencies: + '@langchain/core': + specifier: 0.3.1 + version: 0.3.1(openai@4.61.1(zod@3.23.8)) + '@sap-ai-sdk/ai-api': + specifier: workspace:^ + version: link:../ai-api + '@sap-ai-sdk/foundation-models': + specifier: workspace:^ + version: link:../foundation-models + zod-to-json-schema: + specifier: ^3.23.2 + version: 3.23.3(zod@3.23.8) + devDependencies: + typescript: + specifier: ^5.5.4 + version: 5.6.2 + packages/orchestration: dependencies: '@sap-ai-sdk/ai-api': @@ -182,12 +201,21 @@ importers: sample-code: dependencies: + '@langchain/core': + specifier: 0.3.1 + version: 0.3.1(openai@4.61.1(zod@3.23.8)) + '@langchain/openai': + specifier: 0.3.0 + version: 0.3.0(@langchain/core@0.3.1(openai@4.61.1(zod@3.23.8))) '@sap-ai-sdk/ai-api': specifier: workspace:^ version: link:../packages/ai-api '@sap-ai-sdk/foundation-models': specifier: workspace:^ version: link:../packages/foundation-models + '@sap-ai-sdk/langchain': + specifier: workspace:^ + version: link:../packages/langchain '@sap-ai-sdk/orchestration': specifier: workspace:^ version: link:../packages/orchestration @@ -724,6 +752,16 @@ packages: '@jsdevtools/ono@7.1.3': resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} + '@langchain/core@0.3.1': + resolution: {integrity: sha512-xYdTAgS9hYPt+h0/OwpyRcMB5HKR40LXutbSr2jw3hMVIOwD1DnvhnUEnWgBK4lumulVW2jrosNPyBKMhRZAZg==} + engines: {node: '>=18'} + + '@langchain/openai@0.3.0': + resolution: {integrity: sha512-yXrz5Qn3t9nq3NQAH2l4zZOI4ev2CFdLC5kvmi5SdW4bggRuM40SXTUAY3VRld4I5eocYfk82VbrlA+6dvN5EA==} + engines: {node: '>=18'} + peerDependencies: + '@langchain/core': '>=0.2.26 <0.4.0' + '@manypkg/find-root@1.1.0': resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} @@ -907,9 +945,15 @@ packages: '@types/mock-fs@4.13.4': resolution: {integrity: sha512-mXmM0o6lULPI8z3XNnQCpL0BGxPwx1Ul1wXYEPBGl4efShyxW2Rln0JOPEWGyZaYZMM6OVXM/15zUuFMY52ljg==} + '@types/node-fetch@2.6.11': + resolution: {integrity: sha512-24xFj9R5+rfQJLRyM56qh+wnVSYhyXC2tkoBndtY0U+vubqNsYXGjufB2nn8Q6gt0LrARwL6UBtMCSVCwl4B1g==} + '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} + '@types/node@18.19.50': + resolution: {integrity: sha512-xonK+NRrMBRtkL1hVCc3G+uXtjh1Al4opBLjqVmipe5ZAaBYWW6cNAiBVZ1BvmkBhep698rP3UM3aRAdSALuhg==} + '@types/node@20.16.5': resolution: {integrity: sha512-VwYCweNo3ERajwy0IUlqqcyZ8/A7Zwa9ZP3MnENWcB11AejO+tLy3pu850goUW2FC/IJMdZUfKpX/yxL1gymCA==} @@ -922,6 +966,9 @@ packages: '@types/range-parser@1.2.7': resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + '@types/retry@0.12.0': + resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + '@types/retry@0.12.5': resolution: {integrity: sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==} @@ -940,6 +987,9 @@ packages: '@types/triple-beam@1.3.5': resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} + '@types/uuid@10.0.0': + resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -1012,6 +1062,10 @@ packages: '@ungap/structured-clone@1.2.0': resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + accepts@1.3.8: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} @@ -1030,6 +1084,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agentkeepalive@4.5.0: + resolution: {integrity: sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==} + engines: {node: '>= 8.0.0'} + ajv-draft-04@1.0.0: resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} peerDependencies: @@ -1358,6 +1416,10 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} + comment-parser@1.4.1: resolution: {integrity: sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==} engines: {node: '>= 12.0.0'} @@ -1771,6 +1833,13 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -1890,10 +1959,17 @@ packages: resolution: {integrity: sha512-PXUUyLqrR2XCWICfv6ukppP96sdFwWbNEnfEMt7jNsISjMsvaLNinAHNDYyvkyU+SZG2BTSbT5NjG+vZslfGTA==} engines: {node: '>=14'} + form-data-encoder@1.7.2: + resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==} + form-data@4.0.0: resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==} engines: {node: '>= 6'} + formdata-node@4.4.1: + resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==} + engines: {node: '>= 12.20'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -2060,6 +2136,9 @@ packages: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} + humanize-ms@1.2.1: + resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} + iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -2455,6 +2534,9 @@ packages: node-notifier: optional: true + js-tiktoken@1.0.14: + resolution: {integrity: sha512-Pk3l3WOgM9joguZY2k52+jH82RtABRgB5RdGFZNUGbOKGMVlNmafcPA3b0ITcCZPu1L9UclP1tne6aw7ZI4Myg==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2532,6 +2614,14 @@ packages: kuler@2.0.0: resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==} + langsmith@0.1.56-rc.1: + resolution: {integrity: sha512-XsOxlhBAlTCGR9hNEL2VSREmiz8v6czNuX3CIwec9fH9T0WbNPle8Q/7Jy/h9UCbS9vuzTjfgc4qO5Dc9cu5Ig==} + peerDependencies: + openai: '*' + peerDependenciesMeta: + openai: + optional: true + leven@3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} @@ -2722,6 +2812,10 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + mustache@4.2.0: + resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} + hasBin: true + mute-stream@0.0.8: resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} @@ -2740,6 +2834,10 @@ packages: resolution: {integrity: sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==} engines: {node: '>= 8.0.0'} + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + node-fetch-h2@2.3.0: resolution: {integrity: sha512-ofRW94Ab0T4AOh5Fk8t0h8OBWrmjb0SSB20xh1H8YnPV9EJ+f5AMoYSUQ2zgJ4Iq2HAK0I2l5/Nequ8YzFS3Hg==} engines: {node: 4.x || >=6.0.0} @@ -2834,6 +2932,15 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} + openai@4.61.1: + resolution: {integrity: sha512-jZ2WRn+f4QWZkYnrUS+xzEUIBllsGN75dUCaXmMIHcv2W9yn7O8amaReTbGHCNEYkL43vuDOcxPUWfNPUmoD3Q==} + hasBin: true + peerDependencies: + zod: ^3.23.8 + peerDependenciesMeta: + zod: + optional: true + openapi-types@12.1.3: resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} @@ -2860,6 +2967,10 @@ packages: resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==} engines: {node: '>=8'} + p-finally@1.0.0: + resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} + engines: {node: '>=4'} + p-limit@2.3.0: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} engines: {node: '>=6'} @@ -2880,6 +2991,18 @@ packages: resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==} engines: {node: '>=6'} + p-queue@6.6.2: + resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==} + engines: {node: '>=8'} + + p-retry@4.6.2: + resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} + engines: {node: '>=8'} + + p-timeout@3.2.0: + resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==} + engines: {node: '>=8'} + p-try@2.2.0: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} @@ -3534,6 +3657,9 @@ packages: unbox-primitive@1.0.2: resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==} + undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + undici-types@6.19.6: resolution: {integrity: sha512-e/vggGopEfTKSvj4ihnOLTsqhrKRN3LeO6qSN/GxohhuRv8qH9bNQ4B8W7e/vFL+0XTnmHPB4/kegunZGA4Org==} @@ -3565,6 +3691,10 @@ packages: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} + uuid@10.0.0: + resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + hasBin: true + v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} @@ -3592,6 +3722,10 @@ packages: wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + web-streams-polyfill@4.0.0-beta.3: + resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==} + engines: {node: '>= 14'} + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -3693,6 +3827,11 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod-to-json-schema@3.23.3: + resolution: {integrity: sha512-TYWChTxKQbRJp5ST22o/Irt9KC5nj7CdBKYB/AosCRdj/wxEMvv4NNaj9XVUHDOIp53ZxArGhnw5HMZziPFjog==} + peerDependencies: + zod: ^3.23.3 + zod@3.23.8: resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} @@ -4441,6 +4580,32 @@ snapshots: '@jsdevtools/ono@7.1.3': {} + '@langchain/core@0.3.1(openai@4.61.1(zod@3.23.8))': + dependencies: + ansi-styles: 5.2.0 + camelcase: 6.3.0 + decamelize: 1.2.0 + js-tiktoken: 1.0.14 + langsmith: 0.1.56-rc.1(openai@4.61.1(zod@3.23.8)) + mustache: 4.2.0 + p-queue: 6.6.2 + p-retry: 4.6.2 + uuid: 10.0.0 + zod: 3.23.8 + zod-to-json-schema: 3.23.3(zod@3.23.8) + transitivePeerDependencies: + - openai + + '@langchain/openai@0.3.0(@langchain/core@0.3.1(openai@4.61.1(zod@3.23.8)))': + dependencies: + '@langchain/core': 0.3.1(openai@4.61.1(zod@3.23.8)) + js-tiktoken: 1.0.14 + openai: 4.61.1(zod@3.23.8) + zod: 3.23.8 + zod-to-json-schema: 3.23.3(zod@3.23.8) + transitivePeerDependencies: + - encoding + '@manypkg/find-root@1.1.0': dependencies: '@babel/runtime': 7.25.0 @@ -4778,8 +4943,17 @@ snapshots: dependencies: '@types/node': 20.16.5 + '@types/node-fetch@2.6.11': + dependencies: + '@types/node': 20.16.5 + form-data: 4.0.0 + '@types/node@12.20.55': {} + '@types/node@18.19.50': + dependencies: + undici-types: 5.26.5 + '@types/node@20.16.5': dependencies: undici-types: 6.19.6 @@ -4790,6 +4964,8 @@ snapshots: '@types/range-parser@1.2.7': {} + '@types/retry@0.12.0': {} + '@types/retry@0.12.5': {} '@types/semver@7.5.8': {} @@ -4809,6 +4985,8 @@ snapshots: '@types/triple-beam@1.3.5': {} + '@types/uuid@10.0.0': {} + '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.32': @@ -4905,6 +5083,10 @@ snapshots: '@ungap/structured-clone@1.2.0': {} + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + accepts@1.3.8: dependencies: mime-types: 2.1.35 @@ -4920,6 +5102,10 @@ snapshots: acorn@8.12.1: {} + agentkeepalive@4.5.0: + dependencies: + humanize-ms: 1.2.1 + ajv-draft-04@1.0.0(ajv@8.16.0): optionalDependencies: ajv: 8.16.0 @@ -5302,6 +5488,8 @@ snapshots: dependencies: delayed-stream: 1.0.0 + commander@10.0.1: {} + comment-parser@1.4.1: {} concat-map@0.0.1: {} @@ -5735,6 +5923,10 @@ snapshots: etag@1.8.1: {} + event-target-shim@5.0.1: {} + + eventemitter3@4.0.7: {} + execa@5.1.1: dependencies: cross-spawn: 7.0.3 @@ -5904,12 +6096,19 @@ snapshots: cross-spawn: 7.0.3 signal-exit: 4.1.0 + form-data-encoder@1.7.2: {} + form-data@4.0.0: dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 mime-types: 2.1.35 + formdata-node@4.4.1: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 4.0.0-beta.3 + forwarded@0.2.0: {} fresh@0.5.2: {} @@ -6077,6 +6276,10 @@ snapshots: human-signals@2.1.0: {} + humanize-ms@1.2.1: + dependencies: + ms: 2.1.3 + iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 @@ -6707,6 +6910,10 @@ snapshots: - supports-color - ts-node + js-tiktoken@1.0.14: + dependencies: + base64-js: 1.5.1 + js-tokens@4.0.0: {} js-yaml@3.14.1: @@ -6784,6 +6991,17 @@ snapshots: kuler@2.0.0: {} + langsmith@0.1.56-rc.1(openai@4.61.1(zod@3.23.8)): + dependencies: + '@types/uuid': 10.0.0 + commander: 10.0.1 + p-queue: 6.6.2 + p-retry: 4.6.2 + semver: 7.6.3 + uuid: 10.0.0 + optionalDependencies: + openai: 4.61.1(zod@3.23.8) + leven@3.1.0: {} levn@0.4.1: @@ -6948,6 +7166,8 @@ snapshots: ms@2.1.3: {} + mustache@4.2.0: {} + mute-stream@0.0.8: {} natural-compare@1.4.0: {} @@ -6966,6 +7186,8 @@ snapshots: dependencies: clone: 2.1.2 + node-domexception@1.0.0: {} + node-fetch-h2@2.3.0: dependencies: http2-client: 1.3.5 @@ -7081,6 +7303,22 @@ snapshots: dependencies: mimic-fn: 2.1.0 + openai@4.61.1(zod@3.23.8): + dependencies: + '@types/node': 18.19.50 + '@types/node-fetch': 2.6.11 + '@types/qs': 6.9.15 + abort-controller: 3.0.0 + agentkeepalive: 4.5.0 + form-data-encoder: 1.7.2 + formdata-node: 4.4.1 + node-fetch: 2.7.0 + qs: 6.13.0 + optionalDependencies: + zod: 3.23.8 + transitivePeerDependencies: + - encoding + openapi-types@12.1.3: {} opossum@8.1.4: {} @@ -7114,6 +7352,8 @@ snapshots: dependencies: p-map: 2.1.0 + p-finally@1.0.0: {} + p-limit@2.3.0: dependencies: p-try: 2.2.0 @@ -7132,6 +7372,20 @@ snapshots: p-map@2.1.0: {} + p-queue@6.6.2: + dependencies: + eventemitter3: 4.0.7 + p-timeout: 3.2.0 + + p-retry@4.6.2: + dependencies: + '@types/retry': 0.12.0 + retry: 0.13.1 + + p-timeout@3.2.0: + dependencies: + p-finally: 1.0.0 + p-try@2.2.0: {} package-json-from-dist@1.0.0: {} @@ -7817,6 +8071,8 @@ snapshots: has-symbols: 1.0.3 which-boxed-primitive: 1.0.2 + undici-types@5.26.5: {} + undici-types@6.19.6: {} universalify@0.1.2: {} @@ -7839,6 +8095,8 @@ snapshots: utils-merge@1.0.1: {} + uuid@10.0.0: {} + v8-compile-cache-lib@3.0.1: {} v8-to-istanbul@9.3.0: @@ -7870,6 +8128,8 @@ snapshots: dependencies: defaults: 1.0.4 + web-streams-polyfill@4.0.0-beta.3: {} + webidl-conversions@3.0.1: {} whatwg-url@5.0.0: @@ -7987,4 +8247,8 @@ snapshots: yocto-queue@0.1.0: {} + zod-to-json-schema@3.23.3(zod@3.23.8): + dependencies: + zod: 3.23.8 + zod@3.23.8: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8593a021..aeabc7c7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,7 @@ packages: - 'packages/foundation-models' - 'packages/orchestration' - 'packages/core' + - 'packages/langchain' - 'sample-code' - 'tests/e2e-tests' - 'tests/type-tests' diff --git a/sample-code/package.json b/sample-code/package.json index cc27f45b..c936b234 100644 --- a/sample-code/package.json +++ b/sample-code/package.json @@ -26,6 +26,9 @@ "@sap-ai-sdk/ai-api": "workspace:^", "@sap-ai-sdk/foundation-models": "workspace:^", "@sap-ai-sdk/orchestration": "workspace:^", + "@sap-ai-sdk/langchain": "workspace:^", + "@langchain/openai": "0.3.0", + "@langchain/core": "0.3.1", "@types/express": "^4.17.21", "express": "^4.21.0" } diff --git a/sample-code/src/index.ts b/sample-code/src/index.ts index 4d5e91d4..25db0a13 100644 --- a/sample-code/src/index.ts +++ b/sample-code/src/index.ts @@ -3,3 +3,10 @@ export { chatCompletion, computeEmbedding } from './foundation-models-azure-openai.js'; + +export { + embedQuery, + embedDocument, + simpleInvoke, + complexInvoke +} from './langchain-azure-openai.js'; diff --git a/sample-code/src/langchain-azure-openai.ts b/sample-code/src/langchain-azure-openai.ts new file mode 100644 index 00000000..f21e9c7c --- /dev/null +++ b/sample-code/src/langchain-azure-openai.ts @@ -0,0 +1,56 @@ +import { StringOutputParser } from '@langchain/core/output_parsers'; +import { ChatPromptTemplate } from '@langchain/core/prompts'; +import { + AzureOpenAiChatClient, + AzureOpenAiEmbeddingClient +} from '@sap-ai-sdk/langchain'; + +/** + * Ask GPT about the capital of France. + * @returns The answer from GPT. + */ +export async function simpleInvoke(): Promise { + const client = new AzureOpenAiChatClient({ modelName: 'gpt-35-turbo' }); + const parser = new StringOutputParser(); + return client.pipe(parser).invoke('What is the capital of France?'); +} + +/** + * Ask GPT about the capital of France, with a more complex prompt. + * @returns The answer from GPT. + */ +export async function complexInvoke(): Promise { + const client = new AzureOpenAiChatClient({ modelName: 'gpt-35-turbo' }); + const promptTemplate = ChatPromptTemplate.fromMessages([ + ['system', 'Answer the following in {language}:'], + ['user', '{text}'] + ]); + const parser = new StringOutputParser(); + const llmChain = promptTemplate.pipe(client).pipe(parser); + return llmChain.invoke({ + language: 'german', + text: 'What is the capital of France?' + }); +} + +/** + * Embed 'Hello, world!' using the OpenAI ADA model. + * @returns An embedding vector. + */ +export async function embedQuery(): Promise { + const client = new AzureOpenAiEmbeddingClient({ + modelName: 'text-embedding-ada-002' + }); + return client.embedQuery('Hello, world!'); +} + +/** + * Embed 'Hello, world!' and 'Goodbye, world!' using the OpenAI ADA model. + * @returns An array of embedding vectors. + */ +export async function embedDocument(): Promise { + const client = new AzureOpenAiEmbeddingClient({ + modelName: 'text-embedding-ada-002' + }); + return client.embedDocuments(['Hello, world!', 'Goodbye, world!']); +} diff --git a/sample-code/src/server.ts b/sample-code/src/server.ts index e2ba3447..2e8eb6c8 100644 --- a/sample-code/src/server.ts +++ b/sample-code/src/server.ts @@ -6,6 +6,12 @@ import { } from './foundation-models-azure-openai.js'; import { orchestrationCompletion } from './orchestration.js'; import { getDeployments } from './ai-api.js'; +import { + complexInvoke, + embedDocument, + embedQuery, + simpleInvoke +} from './langchain-azure-openai.js'; const app = express(); const port = 8080; @@ -28,10 +34,11 @@ app.get('/llm', async (req, res) => { app.get('/embedding', async (req, res) => { try { const result = await computeEmbedding(); - if (result.length === 0) { - throw new Error('No embedding vector returned'); + if (!result.length) { + res.status(500).send('No embedding vector returned.'); + } else { + res.send('Number crunching success, got a nice vector.'); } - res.send('Number crunching success, got a nice vector.'); } catch (error: any) { console.error(error); res @@ -62,6 +69,60 @@ app.get('/ai-api/get-deployments', async (req, res) => { } }); +app.get('/langchain/chat', async (req, res) => { + try { + res.send(await simpleInvoke()); + } catch (error: any) { + console.error(error); + res + .status(500) + .send('Yikes, vibes are off apparently 😬 -> ' + error.message); + } +}); + +app.get('/langchain/complex-chat', async (req, res) => { + try { + res.send(await complexInvoke()); + } catch (error: any) { + console.error(error); + res + .status(500) + .send('Yikes, vibes are off apparently 😬 -> ' + error.message); + } +}); + +app.get('/langchain/embed-query', async (req, res) => { + try { + const result = await embedQuery(); + if (!result.length) { + res.status(500).send('No embedding vector returned.'); + } else { + res.send('Number crunching success, got a nice vector.'); + } + } catch (error: any) { + console.error(error); + res + .status(500) + .send('Yikes, vibes are off apparently 😬 -> ' + error.message); + } +}); + +app.get('/langchain/embed-document', async (req, res) => { + try { + const result = await embedDocument(); + if (!result.length) { + res.status(500).send('No embedding vector returned.'); + } else { + res.send('Number crunching success, got a nice vector.'); + } + } catch (error: any) { + console.error(error); + res + .status(500) + .send('Yikes, vibes are off apparently 😬 -> ' + error.message); + } +}); + app.listen(port, () => { console.log(`Server running at http://localhost:${port}`); }); diff --git a/test-util/mock-http.ts b/test-util/mock-http.ts index 59beaa77..f6a0026b 100644 --- a/test-util/mock-http.ts +++ b/test-util/mock-http.ts @@ -119,17 +119,15 @@ export function mockDeploymentsList( opts: DeploymentResolutionOptions, ...deployments: { id: string; model?: FoundationModel }[] ): nock.Scope { - const nockOpts = opts?.resourceGroup - ? { - reqheaders: { - 'ai-resource-group': opts?.resourceGroup - } - } - : undefined; + const nockOpts = { + reqheaders: { + 'ai-resource-group': opts?.resourceGroup ?? 'default', + } + }; const query = { status: 'RUNNING', scenarioId: opts.scenarioId, - ...(opts.executableId && { executableIds: [opts.executableId] }) + ...(opts.executableId && { executableIds: [opts.executableId].toString() }) }; return nock(aiCoreDestination.url, nockOpts) .get('/v2/lm/deployments') diff --git a/tests/e2e-tests/src/foundation-models.test.ts b/tests/e2e-tests/src/foundation-models.test.ts index a2ad3dd4..32598ecc 100644 --- a/tests/e2e-tests/src/foundation-models.test.ts +++ b/tests/e2e-tests/src/foundation-models.test.ts @@ -6,13 +6,11 @@ loadEnv(); describe('Azure OpenAI Foundation Model Access', () => { it('should complete a chat', async () => { const result = await chatCompletion(); - expect(result).toBeDefined(); expect(result).toContain('Paris'); }); it('should compute an embedding vector', async () => { const result = await computeEmbedding(); - expect(result).toBeDefined(); expect(result).not.toHaveLength(0); }); }); diff --git a/tests/e2e-tests/src/open-ai-langchain.test.ts b/tests/e2e-tests/src/open-ai-langchain.test.ts new file mode 100644 index 00000000..83eccd0f --- /dev/null +++ b/tests/e2e-tests/src/open-ai-langchain.test.ts @@ -0,0 +1,36 @@ +import path from 'path'; +import { fileURLToPath } from 'url'; +import dotenv from 'dotenv'; +import { + complexInvoke, + embedDocument, + embedQuery, + simpleInvoke +} from '@sap-ai-sdk/sample-code'; + +// Pick .env file from root directory +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +dotenv.config({ path: path.resolve(__dirname, '../.env') }); + +describe('Langchain OpenAI Access', () => { + it('executes a basic invoke', async () => { + const result = await simpleInvoke(); + expect(result).toContain('Paris'); + }); + + it('executes invoke as part of a chain ', async () => { + const result = await complexInvoke(); + expect(result).toContain('Paris'); + }); + + it('should compute an embedding vector based on a string', async () => { + const result = await embedQuery(); + expect(result).not.toHaveLength(0); + }); + + it('should compute an embedding vector based on a string array', async () => { + const result = await embedDocument(); + expect(result).not.toHaveLength(0); + }); +});