diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index b2f7b78b9356..7ee096fcdc10 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -4448,7 +4448,7 @@ packages: dependencies: semver: 7.5.4 shelljs: 0.8.5 - typescript: 5.3.0-dev.20230918 + typescript: 5.3.0-dev.20230919 dev: false /ecdsa-sig-formatter@1.0.11: @@ -5229,6 +5229,10 @@ packages: signal-exit: 3.0.7 dev: false + /form-data-encoder@1.7.2: + resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==} + dev: false + /form-data@3.0.1: resolution: {integrity: sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==} engines: {node: '>= 6'} @@ -5247,6 +5251,14 @@ packages: mime-types: 2.1.35 dev: false + /formdata-node@4.4.1: + resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==} + engines: {node: '>= 12.20'} + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 4.0.0-beta.3 + dev: false + /forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -7125,6 +7137,11 @@ packages: resolution: {integrity: sha512-CvkDw2OEnme7ybCykJpVcKH+uAOLV2qLqiyla128dN9TkEWfrYmxG6C2boDe5KcNQqZF3orkqzGgOMvZ/JNekA==} dev: false + /node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + dev: false + /node-environment-flags@1.0.6: resolution: {integrity: sha512-5Evy2epuL+6TM0lCQGpFIj6KwiEsGh1SrHUhTbNX+sLbBtjidPZFAnVK9y5yU1+h//RitLbRHTIMyxQPtxMdHw==} dependencies: @@ -9060,8 +9077,8 @@ packages: hasBin: true dev: false - /typescript@5.3.0-dev.20230918: - resolution: {integrity: sha512-n0gvj+RwjGVYi2fyz/7Bd2lCrh/uRROVyQtgUwXXG2p6tyCUw8UE+7lOdr2dp3hVn6Uo4EliGLfajoWNqhFmuw==} + /typescript@5.3.0-dev.20230919: + resolution: {integrity: sha512-FU6DZhzId38aY/dX2gHp7phaYkbNJkCx8G//VVs0nVzZv0qjWGggLkMXoMipphO8Hv0TvZu30Zwdt6nzFIbcBQ==} engines: {node: '>=14.17'} hasBin: true dev: false @@ -9234,6 +9251,11 @@ packages: defaults: 1.0.4 dev: false + /web-streams-polyfill@4.0.0-beta.3: + resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==} + engines: {node: '>= 14'} + dev: false + /webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} dev: false @@ -11638,7 +11660,7 @@ packages: dev: false file:projects/arm-containerregistry.tgz: - resolution: {integrity: sha512-MJKuDfBxeSHr7ZQYOxjnvs+v/rfis7GYjw7O+1wKXLbTDMHTAYq3197HTtTycDEA3XG+B0AXodPEuhrw7BM+zg==, tarball: file:projects/arm-containerregistry.tgz} + resolution: {integrity: sha512-fRkiXaz22vrb1482ZJk+NFbFAPs61HZJkzyegJny6M07WriK9w8bWa7BLU+o9vNyJFvX3DNVMad/Rc5mpmebxA==, tarball: file:projects/arm-containerregistry.tgz} name: '@rush-temp/arm-containerregistry' version: 0.0.0 dependencies: @@ -14447,7 +14469,7 @@ packages: dev: false file:projects/arm-network-1.tgz: - resolution: {integrity: sha512-oq6h2+foMLWcZQb9rCJQR5ld706OO7wW2php935hUnBu9kBmoezXz1h7No9rt/Q/rGu6tfwI4OnKvz16UxfSVQ==, tarball: file:projects/arm-network-1.tgz} + resolution: {integrity: sha512-krnhaHsVug4HjfB3CFwoVubbrmkUx6COSb3lv+fyQZ94bbT+idufoLYjlu2GFewn3YhU4WxaXlFmysS5UOnFjw==, tarball: file:projects/arm-network-1.tgz} name: '@rush-temp/arm-network-1' version: 0.0.0 dependencies: @@ -16196,7 +16218,7 @@ packages: dev: false file:projects/arm-signalr.tgz: - resolution: {integrity: sha512-SKOZUTWRwQ29Ktb+EW5udQKUdlxJO0BB5eATI1RotLCAaMZ8JEjhHM+av1tFf5R+hBUJXL1rWnQVxwwsLelKXQ==, tarball: file:projects/arm-signalr.tgz} + resolution: {integrity: sha512-2TXX2h1vcbLy91DuJgaXAYhjLtjvRmTzysxsEPSafLYRiB5vcE+Lqr+gt38SbIaqBg1w55L0l7BNfvyI7eVY1w==, tarball: file:projects/arm-signalr.tgz} name: '@rush-temp/arm-signalr' version: 0.0.0 dependencies: @@ -20117,7 +20139,7 @@ packages: dev: false file:projects/openai.tgz: - resolution: {integrity: sha512-uBRK5mKYQiYiMYOjUbXVuBqsr/CFWrRA4LzmSYAn+faoFA3T7MVfwAb773U+lNqOzo9xMaoyWOL9s7pp582pkA==, tarball: file:projects/openai.tgz} + resolution: {integrity: sha512-WRV1j/1aVnIgMm/N3idnFRuT3yBlsIHTD89SNhEkeYwD5LWtt4+HJCbflVkrOBkdN/zDUARQ8BEpOyHGrUIYAg==, tarball: file:projects/openai.tgz} name: '@rush-temp/openai' version: 0.0.0 dependencies: @@ -20128,6 +20150,8 @@ packages: dotenv: 16.3.1 eslint: 8.48.0 esm: 3.2.25 + form-data-encoder: 1.7.2 + formdata-node: 4.4.1 karma: 6.4.2(debug@4.3.4) karma-chrome-launcher: 3.2.0 karma-coverage: 2.2.1 diff --git a/sdk/openai/openai/CHANGELOG.md b/sdk/openai/openai/CHANGELOG.md index f4ce514603e3..d4622e2c97a9 100644 --- a/sdk/openai/openai/CHANGELOG.md +++ b/sdk/openai/openai/CHANGELOG.md @@ -1,17 +1,18 @@ # Release History -## 1.0.0-beta.6 (Unreleased) +## 1.0.0-beta.6 (2023-09-21) ### Features Added -### Breaking Changes +- Introduces speech to text and translation capabilities for a wide variety of audio file formats. + - Adds `getAudioTranscription` and `getAudioTranslation` methods for transcribing and translating audio files. The result can be either a simple JSON structure with just a `text` field or a more detailed JSON structure containing the text alongside additional information. In addition, VTT (Web Video Text Tracks), SRT (SubRip Text), and plain text formats are also supported. The type of the result depends on the `format` parameter if specified, otherwise, a simple JSON output is assumed. The methods could take as input an optional text prompt to guide the model's style or continue a previous audio segment. The language of the prompt should match that of the audio file. + - The available model at the time of this release supports the following list of audio file formats: m4a, mp3, wav, ogg, flac, webm, mp4, mpga, mpeg, and oga. ### Bugs Fixed -- Return `usage` information when available. -- Return `error` information in `ContentFilterResults` when available. - -### Other Changes +- Returns `usage` information when available. +- Fixes a bug where errors weren't properly being thrown from the streaming methods. +- Returns `error` information in `ContentFilterResults` when available. ## 1.0.0-beta.5 (2023-08-25) diff --git a/sdk/openai/openai/README.md b/sdk/openai/openai/README.md index 7c19aff8afd6..790a201bbd56 100644 --- a/sdk/openai/openai/README.md +++ b/sdk/openai/openai/README.md @@ -6,10 +6,12 @@ non-Azure OpenAI inference endpoint, making it a great choice for even non-Azure Use the client library for Azure OpenAI to: -* [Create a completion for text][msdocs_openai_completion] -* [Create a chat completion with ChatGPT][msdocs_openai_chat_completion] +* [Create a completion for text][get_completions_sample] +* [Create a chat completion with ChatGPT][list_chat_completion_sample] * [Create a text embedding for comparisons][msdocs_openai_embedding] -* [Use your own data with Azure OpenAI][msdocs_openai_custom_data] +* [Use your own data with Azure OpenAI][byod_sample] +* [Generate images][get_images_sample] +* [Transcribe and Translate audio files][transcribe_audio_sample] Azure OpenAI is a managed service that allows developers to deploy, tune, and generate content from OpenAI models on Azure resources. @@ -20,6 +22,7 @@ Checkout the following examples: - [Summarize Text](#summarize-text-with-completion) - [Generate Images](#generate-images-with-dall-e-image-generation-models) - [Analyze Business Data](#analyze-business-data) +- [Transcribe and Translate audio files](#transcribe-and-translate-audio-files) Key links: @@ -140,6 +143,10 @@ async function main(){ console.log(choice.text); } } + +main().catch((err) => { + console.error("The sample encountered an error:", err); +}); ``` ## Examples @@ -179,6 +186,10 @@ async function main(){ } } } + +main().catch((err) => { + console.error("The sample encountered an error:", err); +}); ``` ### Generate Multiple Completions With Subscription Key @@ -212,6 +223,10 @@ async function main(){ console.log(`Chatbot: ${completion}`); } } + +main().catch((err) => { + console.error("The sample encountered an error:", err); +}); ``` ### Summarize Text with Completion @@ -254,6 +269,9 @@ async function main(){ console.log(`Summarization: ${completion}`); } +main().catch((err) => { + console.error("The sample encountered an error:", err); +}); ``` ### Generate images with DALL-E image generation models @@ -276,6 +294,10 @@ async function main() { console.log(`Image generation result URL: ${image.url}`); } } + +main().catch((err) => { + console.error("The sample encountered an error:", err); +}); ``` ### Analyze Business Data @@ -285,7 +307,7 @@ This example generates chat responses to input chat questions about your busines ```javascript const { OpenAIClient } = require("@azure/openai"); -const { DefaultAzureCredential } = require("@azure/identity") +const { DefaultAzureCredential } = require("@azure/identity"); async function main(){ const endpoint = "https://myaccount.openai.azure.com/"; @@ -323,6 +345,36 @@ async function main(){ } } } + +main().catch((err) => { + console.error("The sample encountered an error:", err); +}); +``` + +### Transcribe and translate audio files + +The speech to text and translation capabilities of Azure OpenAI can be used to transcribe and translate a wide variety of audio file formats. The following example shows how to use the `getAudioTranscription` method to transcribe audio into the language the audio is in. You can also translate and transcribe the audio into English using the `getAudioTranslation` method. + +The audio file can be loaded into memory using the NodeJS file system APIs. In the browser, the file can be loaded using the `FileReader` API and the output of `arrayBuffer` instance method can be passed to the `getAudioTranscription` method. + +```js +const { OpenAIClient, AzureKeyCredential } = require("@azure/openai"); +const fs = require("fs/promises"); + +async function main() { + console.log("== Transcribe Audio Sample =="); + + const client = new OpenAIClient(endpoint, new AzureKeyCredential(azureApiKey)); + const deploymentName = "whisper-deployment"; + const audio = await fs.readFile("< path to an audio file >"); + const result = await client.getAudioTranscription(deploymentName, audio); + + console.log(`Transcription: ${result.text}`); +} + +main().catch((err) => { + console.error("The sample encountered an error:", err); +}); ``` ## Troubleshooting @@ -340,9 +392,11 @@ setLogLevel("info"); For more detailed instructions on how to enable logs, you can look at the [@azure/logger package docs](https://github.com/Azure/azure-sdk-for-js/tree/main/sdk/core/logger). -[msdocs_openai_completion]: https://github.com/Azure/azure-sdk-for-js/blob/main/sdk/openai/openai/samples/v1-beta/javascript/completions.js -[msdocs_openai_chat_completion]: https://github.com/Azure/azure-sdk-for-js/blob/main/sdk/openai/openai/samples/v1-beta/javascript/listChatCompletions.js -[msdocs_openai_custom_data]: https://github.com/Azure/azure-sdk-for-js/blob/main/sdk/openai/openai/samples-dev/bringYourOwnData.ts +[get_completions_sample]: https://github.com/Azure/azure-sdk-for-js/blob/main/sdk/openai/openai/samples/v1-beta/javascript/completions.js +[list_chat_completion_sample]: https://github.com/Azure/azure-sdk-for-js/blob/main/sdk/openai/openai/samples/v1-beta/javascript/listChatCompletions.js +[byod_sample]: https://github.com/Azure/azure-sdk-for-js/blob/main/sdk/openai/openai/samples/v1-beta/javascript/bringYourOwnData.js +[get_images_sample]: https://github.com/Azure/azure-sdk-for-js/blob/main/sdk/openai/openai/samples/v1-beta/javascript/getImages.js +[transcribe_audio_sample]: https://github.com/Azure/azure-sdk-for-js/tree/openai/add-whisper/sdk/openai/openai/samples-dev/audioTranscription.ts [msdocs_openai_embedding]: https://learn.microsoft.com/azure/cognitive-services/openai/concepts/understand-embeddings [azure_openai_completions_docs]: https://learn.microsoft.com/azure/cognitive-services/openai/how-to/completions [defaultazurecredential]: https://github.com/Azure/azure-sdk-for-js/tree/main/sdk/identity/identity#defaultazurecredential diff --git a/sdk/openai/openai/assets.json b/sdk/openai/openai/assets.json index 1de6e63acb3f..3211ad753578 100644 --- a/sdk/openai/openai/assets.json +++ b/sdk/openai/openai/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "js", "TagPrefix": "js/openai/openai", - "Tag": "js/openai/openai_353545d522" + "Tag": "js/openai/openai_85d9317957" } diff --git a/sdk/openai/openai/assets/audio/countdown.flac b/sdk/openai/openai/assets/audio/countdown.flac new file mode 100644 index 000000000000..acc7c4225e70 Binary files /dev/null and b/sdk/openai/openai/assets/audio/countdown.flac differ diff --git a/sdk/openai/openai/assets/audio/countdown.m4a b/sdk/openai/openai/assets/audio/countdown.m4a new file mode 100644 index 000000000000..7d8b6cf2df1b Binary files /dev/null and b/sdk/openai/openai/assets/audio/countdown.m4a differ diff --git a/sdk/openai/openai/assets/audio/countdown.mp3 b/sdk/openai/openai/assets/audio/countdown.mp3 new file mode 100644 index 000000000000..95d2a332d5cd Binary files /dev/null and b/sdk/openai/openai/assets/audio/countdown.mp3 differ diff --git a/sdk/openai/openai/assets/audio/countdown.mp4 b/sdk/openai/openai/assets/audio/countdown.mp4 new file mode 100644 index 000000000000..6c695af9b85e Binary files /dev/null and b/sdk/openai/openai/assets/audio/countdown.mp4 differ diff --git a/sdk/openai/openai/assets/audio/countdown.mpeg b/sdk/openai/openai/assets/audio/countdown.mpeg new file mode 100644 index 000000000000..958768085592 Binary files /dev/null and b/sdk/openai/openai/assets/audio/countdown.mpeg differ diff --git a/sdk/openai/openai/assets/audio/countdown.mpga b/sdk/openai/openai/assets/audio/countdown.mpga new file mode 100644 index 000000000000..95d2a332d5cd Binary files /dev/null and b/sdk/openai/openai/assets/audio/countdown.mpga differ diff --git a/sdk/openai/openai/assets/audio/countdown.oga b/sdk/openai/openai/assets/audio/countdown.oga new file mode 100644 index 000000000000..ddf221a7c5e5 Binary files /dev/null and b/sdk/openai/openai/assets/audio/countdown.oga differ diff --git a/sdk/openai/openai/assets/audio/countdown.ogg b/sdk/openai/openai/assets/audio/countdown.ogg new file mode 100644 index 000000000000..324141dabd14 Binary files /dev/null and b/sdk/openai/openai/assets/audio/countdown.ogg differ diff --git a/sdk/openai/openai/assets/audio/countdown.wav b/sdk/openai/openai/assets/audio/countdown.wav new file mode 100644 index 000000000000..826fdb19bd11 Binary files /dev/null and b/sdk/openai/openai/assets/audio/countdown.wav differ diff --git a/sdk/openai/openai/assets/audio/countdown.webm b/sdk/openai/openai/assets/audio/countdown.webm new file mode 100644 index 000000000000..c0f503b8b57e Binary files /dev/null and b/sdk/openai/openai/assets/audio/countdown.webm differ diff --git a/sdk/openai/openai/package.json b/sdk/openai/openai/package.json index 9fa013606d05..20f80681b824 100644 --- a/sdk/openai/openai/package.json +++ b/sdk/openai/openai/package.json @@ -7,6 +7,7 @@ "module": "dist-esm/src/index.js", "browser": { "./dist-esm/src/api/getSSEs.js": "./dist-esm/src/api/getSSEs.browser.js", + "./dist-esm/src/api/policies/formDataPolicy.js": "./dist-esm/src/api/policies/formDataPolicy.browser.js", "./dist-esm/test/public/utils/getImageDimensions.js": "./dist-esm/test/public/utils/getImageDimensions.browser.js" }, "type": "module", @@ -136,6 +137,8 @@ "@azure/core-lro": "^2.5.3", "@azure/core-rest-pipeline": "^1.10.2", "@azure/logger": "^1.0.3", + "formdata-node": "^4.0.0", + "form-data-encoder": "1.7.2", "tslib": "^2.4.0" }, "//sampleConfiguration": { diff --git a/sdk/openai/openai/review/openai.api.md b/sdk/openai/openai/review/openai.api.md index c6c5bbb9dfe7..df73093240fd 100644 --- a/sdk/openai/openai/review/openai.api.md +++ b/sdk/openai/openai/review/openai.api.md @@ -11,6 +11,58 @@ import { KeyCredential } from '@azure/core-auth'; import { OperationOptions } from '@azure-rest/core-client'; import { TokenCredential } from '@azure/core-auth'; +// @public +export type AudioResult = { + json: AudioResultSimpleJson; + verbose_json: AudioResultVerboseJson; + vtt: string; + srt: string; + text: string; +}[ResponseFormat]; + +// @public +export type AudioResultFormat = +/** This format will return an JSON structure containing a single \"text\" with the transcription. */ +"json" +/** This format will return an JSON structure containing an enriched structure with the transcription. */ +| "verbose_json" +/** This will make the response return the transcription as plain/text. */ +| "text" +/** The transcription will be provided in SRT format (SubRip Text) in the form of plain/text. */ +| "srt" +/** The transcription will be provided in VTT format (Web Video Text Tracks) in the form of plain/text. */ +| "vtt"; + +// @public +export interface AudioResultSimpleJson { + text: string; +} + +// @public +export interface AudioResultVerboseJson extends AudioResultSimpleJson { + duration: number; + language: string; + segments: AudioSegment[]; + task: AudioTranscriptionTask; +} + +// @public +export interface AudioSegment { + avgLogprob: number; + compressionRatio: number; + end: number; + id: number; + noSpeechProb: number; + seek: number; + start: number; + temperature: number; + text: string; + tokens: number[]; +} + +// @public +export type AudioTranscriptionTask = string; + // @public export interface AzureChatExtensionConfiguration { parameters: Record; @@ -184,6 +236,21 @@ export interface FunctionName { name: string; } +// @public +export interface GetAudioTranscriptionOptions extends OperationOptions { + language?: string; + model?: string; + prompt?: string; + temperature?: number; +} + +// @public +export interface GetAudioTranslationOptions extends OperationOptions { + model?: string; + prompt?: string; + temperature?: number; +} + // @public export interface GetChatCompletionsOptions extends OperationOptions { azureExtensionOptions?: AzureExtensionsOptions; @@ -261,6 +328,10 @@ export class OpenAIClient { constructor(endpoint: string, credential: KeyCredential, options?: OpenAIClientOptions); constructor(endpoint: string, credential: TokenCredential, options?: OpenAIClientOptions); constructor(openAiApiKey: KeyCredential, options?: OpenAIClientOptions); + getAudioTranscription(deploymentName: string, fileContent: Uint8Array, options?: GetAudioTranscriptionOptions): Promise; + getAudioTranscription(deploymentName: string, fileContent: Uint8Array, format: Format, options?: GetAudioTranscriptionOptions): Promise>; + getAudioTranslation(deploymentName: string, fileContent: Uint8Array, options?: GetAudioTranslationOptions): Promise; + getAudioTranslation(deploymentName: string, fileContent: Uint8Array, format: Format, options?: GetAudioTranslationOptions): Promise>; getChatCompletions(deploymentName: string, messages: ChatMessage[], options?: GetChatCompletionsOptions): Promise; getCompletions(deploymentName: string, prompt: string[], options?: GetCompletionsOptions): Promise; getEmbeddings(deploymentName: string, input: string[], options?: GetEmbeddingsOptions): Promise; diff --git a/sdk/openai/openai/samples-dev/audioTranscription.ts b/sdk/openai/openai/samples-dev/audioTranscription.ts new file mode 100644 index 000000000000..3bdb51615371 --- /dev/null +++ b/sdk/openai/openai/samples-dev/audioTranscription.ts @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Demonstrates how to transcribe the content of an audio file. + * + * @summary audio transcription. + * @azsdk-weight 100 + */ + +import { OpenAIClient, AzureKeyCredential } from "@azure/openai"; +import { readFile } from "fs/promises"; + +// Load the .env file if it exists +import dotenv from "dotenv"; +dotenv.config(); + +// You will need to set these environment variables or edit the following values +const endpoint = process.env["ENDPOINT"] || ""; +const azureApiKey = process.env["AZURE_API_KEY"] || ""; + +export async function main() { + console.log("== Transcribe Audio Sample =="); + + const client = new OpenAIClient(endpoint, new AzureKeyCredential(azureApiKey)); + const deploymentName = "whisper-deployment"; + const audio = await readFile("./assets/audio/countdown.wav"); + const result = await client.getAudioTranscription(deploymentName, audio); + + console.log(`Transcription: ${result.text}`); +} + +main().catch((err) => { + console.error("The sample encountered an error:", err); +}); diff --git a/sdk/openai/openai/samples-dev/audioTranslation.ts b/sdk/openai/openai/samples-dev/audioTranslation.ts new file mode 100644 index 000000000000..757c66bd88f3 --- /dev/null +++ b/sdk/openai/openai/samples-dev/audioTranslation.ts @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Demonstrates how to translate the content of an audio file. + * + * @summary audio translation. + * @azsdk-weight 100 + */ + +import { OpenAIClient, AzureKeyCredential } from "@azure/openai"; +import { readFile } from "fs/promises"; + +// Load the .env file if it exists +import dotenv from "dotenv"; +dotenv.config(); + +// You will need to set these environment variables or edit the following values +const endpoint = process.env["ENDPOINT"] || ""; +const azureApiKey = process.env["AZURE_API_KEY"] || ""; + +export async function main() { + console.log("== Translate Audio Sample =="); + + const client = new OpenAIClient(endpoint, new AzureKeyCredential(azureApiKey)); + const deploymentName = "whisper-deployment"; + const audio = await readFile("./assets/audio/countdown.wav"); + const result = await client.getAudioTranslation(deploymentName, audio); + + console.log(`Translation: ${result.text}`); +} + +main().catch((err) => { + console.error("The sample encountered an error:", err); +}); diff --git a/sdk/openai/openai/samples-dev/listChatCompletionsWithContentFilter.ts b/sdk/openai/openai/samples-dev/listChatCompletionsWithContentFilter.ts index 47ad43d5f512..9bcf215579d9 100644 --- a/sdk/openai/openai/samples-dev/listChatCompletionsWithContentFilter.ts +++ b/sdk/openai/openai/samples-dev/listChatCompletionsWithContentFilter.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. /** - * Demonstrates how to get completions for the provided prompt and parse output for content filter + * Demonstrates how to get completions for the provided prompt and parse output for content filter * * @summary get completions. * @azsdk-weight 100 @@ -45,9 +45,7 @@ export async function main() { ); } else { const { hate, sexual, selfHarm, violence } = choice.contentFilterResults; - console.log( - `Hate category is filtered: ${hate?.filtered} with ${hate?.severity} severity` - ); + console.log(`Hate category is filtered: ${hate?.filtered} with ${hate?.severity} severity`); console.log( `Sexual category is filtered: ${sexual?.filtered} with ${sexual?.severity} severity` ); diff --git a/sdk/openai/openai/sources/customizations/OpenAIClient.ts b/sdk/openai/openai/sources/customizations/OpenAIClient.ts index 72acd14a6441..9e648f1c22e9 100644 --- a/sdk/openai/openai/sources/customizations/OpenAIClient.ts +++ b/sdk/openai/openai/sources/customizations/OpenAIClient.ts @@ -5,6 +5,8 @@ import { TokenCredential, KeyCredential, isTokenCredential } from "@azure/core-a import { GetCompletionsOptions, GetEmbeddingsOptions } from "../generated/src/models/options.js"; import { OpenAIClientOptions } from "../generated/src/index.js"; import { + getAudioTranscription, + getAudioTranslation, getChatCompletions, getImages, listChatCompletions, @@ -22,7 +24,15 @@ import { OpenAIContext } from "../generated/src/rest/index.js"; import { createOpenAI } from "../generated/src/api/OpenAIContext.js"; import { GetChatCompletionsOptions } from "./api/models.js"; import { ImageGenerationOptions } from "./models/options.js"; -import { PipelinePolicy } from "@azure/core-rest-pipeline"; +import { nonAzurePolicy } from "./api/policies/nonAzure.js"; +import { formDataPolicyName, formDataWithFileUploadPolicy } from "./api/policies/formDataPolicy.js"; +import { + AudioResult, + AudioResultFormat, + AudioResultSimpleJson, + GetAudioTranscriptionOptions, + GetAudioTranslationOptions, +} from "./models/audio.js"; function createOpenAIEndpoint(version: number): string { return `https://api.openai.com/v${version}`; @@ -131,11 +141,14 @@ export class OpenAIClient { ...(opts.additionalPolicies ?? []), { position: "perCall", - policy: getPolicy(), + policy: nonAzurePolicy(), }, ], }), }); + + this._client.pipeline.removePolicy({ name: formDataPolicyName }); + this._client.pipeline.addPolicy(formDataWithFileUploadPolicy()); } private setModel(model: string, options: { model?: string }): void { @@ -236,32 +249,100 @@ export class OpenAIClient { ): Promise { return getImages(this._client, prompt, options); } -} -function getPolicy(): PipelinePolicy { - const policy: PipelinePolicy = { - name: "openAiEndpoint", - sendRequest: (request, next) => { - const obj = new URL(request.url); - const parts = obj.pathname.split("/"); - switch (parts[parts.length - 1]) { - case "completions": - if (parts[parts.length - 2] === "chat") { - obj.pathname = `/${parts[1]}/chat/completions`; - } else { - obj.pathname = `/${parts[1]}/completions`; - } - break; - case "embeddings": - obj.pathname = `/${parts[1]}/embeddings`; - break; - case "generations:submit": - obj.pathname = `/${parts[1]}/images/generations`; - } - obj.searchParams.delete("api-version"); - request.url = obj.toString(); - return next(request); - }, - }; - return policy; + /** + * Returns the transcription of an audio file in a simple JSON format. + * @param deploymentName - The name of the model deployment (when using Azure OpenAI) or model name (when using non-Azure OpenAI) to use for this request. + * @param fileContent - The content of the audio file to transcribe. + * @param options - The options for this audio transcription request. + * @returns The audio transcription result in a simple JSON format. + */ + async getAudioTranscription( + deploymentName: string, + fileContent: Uint8Array, + options?: GetAudioTranscriptionOptions + ): Promise; + /** + * Returns the transcription of an audio file. + * @param deploymentName - The name of the model deployment (when using Azure OpenAI) or model name (when using non-Azure OpenAI) to use for this request. + * @param fileContent - The content of the audio file to transcribe. + * @param format - The format of the result object. See {@link AudioResultFormat} for possible values. + * @param options - The options for this audio transcription request. + * @returns The audio transcription result in a format of your choice. + */ + async getAudioTranscription( + deploymentName: string, + fileContent: Uint8Array, + format: Format, + options?: GetAudioTranscriptionOptions + ): Promise>; + // implementation + async getAudioTranscription( + deploymentName: string, + fileContent: Uint8Array, + formatOrOptions?: Format | GetAudioTranscriptionOptions, + inputOptions?: GetAudioTranscriptionOptions + ): Promise> { + const options = + inputOptions ?? (typeof formatOrOptions === "string" ? {} : formatOrOptions ?? {}); + const response_format = typeof formatOrOptions === "string" ? formatOrOptions : undefined; + this.setModel(deploymentName, options); + if (response_format === undefined) { + return getAudioTranscription(this._client, deploymentName, fileContent, options) as Promise< + AudioResult + >; + } + return getAudioTranscription( + this._client, + deploymentName, + fileContent, + response_format, + options + ); + } + + /** + * Returns the translation of an audio file. + * @param deploymentName - The name of the model deployment (when using Azure OpenAI) or model name (when using non-Azure OpenAI) to use for this request. + * @param fileContent - The content of the audio file to translate. + * @param options - The options for this audio translation request. + * @returns The audio translation result. + */ + async getAudioTranslation( + deploymentName: string, + fileContent: Uint8Array, + options?: GetAudioTranslationOptions + ): Promise; + /** + * Returns the translation of an audio file. + * @param deploymentName - The name of the model deployment (when using Azure OpenAI) or model name (when using non-Azure OpenAI) to use for this request. + * @param fileContent - The content of the audio file to translate. + * @param format - The format of the result object. See {@link AudioResultFormat} for possible values. + * @param options - The options for this audio translation request. + * @returns The audio translation result. + */ + async getAudioTranslation( + deploymentName: string, + fileContent: Uint8Array, + format: Format, + options?: GetAudioTranslationOptions + ): Promise>; + // implementation + async getAudioTranslation( + deploymentName: string, + fileContent: Uint8Array, + formatOrOptions?: Format | GetAudioTranslationOptions, + inputOptions?: GetAudioTranslationOptions + ): Promise> { + const options = + inputOptions ?? (typeof formatOrOptions === "string" ? {} : formatOrOptions ?? {}); + const response_format = typeof formatOrOptions === "string" ? formatOrOptions : undefined; + this.setModel(deploymentName, options); + if (response_format === undefined) { + return getAudioTranslation(this._client, deploymentName, fileContent, options) as Promise< + AudioResult + >; + } + return getAudioTranslation(this._client, deploymentName, fileContent, response_format, options); + } } diff --git a/sdk/openai/openai/sources/customizations/api/getSSEs.browser.ts b/sdk/openai/openai/sources/customizations/api/getSSEs.browser.ts index c6e7661b6b93..829a39497df7 100644 --- a/sdk/openai/openai/sources/customizations/api/getSSEs.browser.ts +++ b/sdk/openai/openai/sources/customizations/api/getSSEs.browser.ts @@ -3,6 +3,7 @@ import { StreamableMethod } from "@azure-rest/core-client"; import { EventMessage, iterateSseStream } from "@azure/core-sse"; +import { wrapError } from "./util.js"; export async function getSSEs( response: StreamableMethod @@ -14,7 +15,45 @@ export async function getSSEs( async function getStream( response: StreamableMethod ): Promise> { - const stream = (await response.asBrowserStream()).body; - if (!stream) throw new Error("No stream found in response. Did you enable the stream option?"); - return stream; + const { body, status } = await response.asBrowserStream(); + if (status !== "200" && body !== undefined) { + const text = await streamToText(body); + throw wrapError(() => JSON.parse(text).error, "Error parsing response body"); + } + if (!body) throw new Error("No stream found in response. Did you enable the stream option?"); + return body; +} + +async function streamToText(stream: ReadableStream): Promise { + const reader = stream.getReader(); + const buffers: Uint8Array[] = []; + let length = 0; + try { + // eslint-disable-next-line no-constant-condition + while (true) { + const { value, done } = await reader.read(); + if (done) { + return new TextDecoder().decode(concatBuffers(buffers, length)); + } + length += value.length; + buffers.push(value); + } + } finally { + reader.releaseLock(); + } +} + +function getBuffersLength(buffers: Uint8Array[]): number { + return buffers.reduce((acc, curr) => acc + curr.length, 0); +} + +function concatBuffers(buffers: Uint8Array[], len?: number): Uint8Array { + const length = len ?? getBuffersLength(buffers); + const res = new Uint8Array(length); + for (let i = 0, pos = 0; i < buffers.length; i++) { + const buffer = buffers[i]; + res.set(buffer, pos); + pos += buffer.length; + } + return res; } diff --git a/sdk/openai/openai/sources/customizations/api/getSSEs.ts b/sdk/openai/openai/sources/customizations/api/getSSEs.ts index 8b5f63f2b415..4292c08422eb 100644 --- a/sdk/openai/openai/sources/customizations/api/getSSEs.ts +++ b/sdk/openai/openai/sources/customizations/api/getSSEs.ts @@ -3,14 +3,8 @@ import { StreamableMethod } from "@azure-rest/core-client"; import { EventMessage, iterateSseStream } from "@azure/core-sse"; - -async function getStream( - response: StreamableMethod -): Promise> { - const stream = (await response.asNodeStream()).body; - if (!stream) throw new Error("No stream found in response. Did you enable the stream option?"); - return stream as AsyncIterable; -} +import { RestError } from "@azure/core-rest-pipeline"; +import { wrapError } from "./util.js"; export async function getSSEs( response: StreamableMethod @@ -18,3 +12,43 @@ export async function getSSEs( const chunkIterator = await getStream(response); return iterateSseStream(chunkIterator); } + +async function getStream( + response: StreamableMethod +): Promise> { + const { body, status } = await response.asNodeStream(); + if (status !== "200" && body !== undefined) { + const text = await streamToText(body); + throw wrapError(() => JSON.parse(text).error, "Error parsing response body"); + } + if (!body) throw new Error("No stream found in response. Did you enable the stream option?"); + return body as AsyncIterable; +} + +function streamToText(stream: NodeJS.ReadableStream): Promise { + return new Promise((resolve, reject) => { + const buffer: Buffer[] = []; + + stream.on("data", (chunk) => { + if (Buffer.isBuffer(chunk)) { + buffer.push(chunk); + } else { + buffer.push(Buffer.from(chunk)); + } + }); + stream.on("end", () => { + resolve(Buffer.concat(buffer).toString("utf8")); + }); + stream.on("error", (e) => { + if (e && e?.name === "AbortError") { + reject(e); + } else { + reject( + new RestError(`Error reading response as text: ${e.message}`, { + code: RestError.PARSE_ERROR, + }) + ); + } + }); + }); +} diff --git a/sdk/openai/openai/sources/customizations/api/index.ts b/sdk/openai/openai/sources/customizations/api/index.ts index fe4d11e2d0bd..e8975594c09b 100644 --- a/sdk/openai/openai/sources/customizations/api/index.ts +++ b/sdk/openai/openai/sources/customizations/api/index.ts @@ -1,13 +1,15 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -export { getChatCompletions, listChatCompletions, listCompletions } from "./operations.js"; export { - getEmbeddings, - getCompletions, - beginAzureBatchImageGeneration, - getAzureBatchImageGenerationOperationStatus, -} from "../../generated/src/api/operations.js"; + getChatCompletions, + listChatCompletions, + listCompletions, + getAudioTranscription, + getAudioTranslation, + getImages, +} from "./operations.js"; +export { getEmbeddings, getCompletions } from "../../generated/src/api/operations.js"; export { createOpenAI, OpenAIContext, diff --git a/sdk/openai/openai/sources/customizations/api/operations.ts b/sdk/openai/openai/sources/customizations/api/operations.ts index d35252154957..99e19b94c49b 100644 --- a/sdk/openai/openai/sources/customizations/api/operations.ts +++ b/sdk/openai/openai/sources/customizations/api/operations.ts @@ -32,6 +32,15 @@ import { ChatCompletions } from "../models/models.js"; import { getChatCompletionsResult, getCompletionsResult } from "./deserializers.js"; import { GetChatCompletionsOptions } from "./models.js"; import { ImageGenerationOptions } from "../models/options.js"; +import { createFile } from "./policies/formDataPolicy.js"; +import { renameKeysToCamelCase } from "./util.js"; +import { + AudioResult, + AudioResultFormat, + AudioResultSimpleJson, + GetAudioTranscriptionOptions, + GetAudioTranslationOptions, +} from "../models/audio.js"; export function listCompletions( context: Client, @@ -151,3 +160,130 @@ export async function getChatCompletions( } return getChatCompletionsResult(result.body); } + +/** + * Returns the translation of an audio file. + * @param context - The context containing the client to use for this request. + * @param deploymentName - The name of the model deployment (when using Azure OpenAI) or model name (when using non-Azure OpenAI) to use for this request. + * @param fileContent - The content of the audio file to translate. + * @param options - The options for this audio translation request. + * @returns The audio translation result. + */ +export async function getAudioTranslation( + context: Client, + deploymentName: string, + fileContent: Uint8Array, + options?: GetAudioTranslationOptions +): Promise; +/** + * Returns the translation of an audio file. + * @param context - The context containing the client to use for this request. + * @param deploymentName - The name of the model deployment (when using Azure OpenAI) or model name (when using non-Azure OpenAI) to use for this request. + * @param fileContent - The content of the audio file to translate. + * @param format - The format of the result object. See {@link AudioResultFormat} for possible values. + * @param options - The options for this audio translation request. + * @returns The audio translation result. + */ +export async function getAudioTranslation( + context: Client, + deploymentName: string, + fileContent: Uint8Array, + format: Format, + options?: GetAudioTranslationOptions +): Promise>; +// implementation +export async function getAudioTranslation( + context: Client, + deploymentName: string, + fileContent: Uint8Array, + formatOrOptions?: Format | GetAudioTranslationOptions, + inputOptions?: GetAudioTranslationOptions +): Promise> { + const options = + inputOptions ?? (typeof formatOrOptions === "string" ? {} : formatOrOptions ?? {}); + const response_format = typeof formatOrOptions === "string" ? formatOrOptions : undefined; + const { temperature, prompt, model, ...rest } = options; + const { body, status } = await context + .pathUnchecked("deployments/{deploymentId}/audio/translations", deploymentName) + .post({ + body: { + file: createFile(fileContent), + ...(response_format && { response_format }), + ...(temperature !== undefined ? { temperature } : {}), + ...(prompt && { prompt }), + ...(model && { model }), + }, + ...rest, + contentType: "multipart/form-data", + }); + if (status !== "200") { + throw body.error; + } + return response_format !== "verbose_json" + ? body + : (renameKeysToCamelCase(body) as AudioResult); +} + +/** + * Returns the transcription of an audio file in a simple JSON format. + * @param context - The context containing the client to use for this request. + * @param deploymentName - The name of the model deployment (when using Azure OpenAI) or model name (when using non-Azure OpenAI) to use for this request. + * @param fileContent - The content of the audio file to transcribe. + * @param options - The options for this audio transcription request. + * @returns The audio transcription result in a simple JSON format. + */ +export async function getAudioTranscription( + context: Client, + deploymentName: string, + fileContent: Uint8Array, + options?: GetAudioTranscriptionOptions +): Promise; +/** + * Returns the transcription of an audio file. + * @param context - The context containing the client to use for this request. + * @param deploymentName - The name of the model deployment (when using Azure OpenAI) or model name (when using non-Azure OpenAI) to use for this request. + * @param fileContent - The content of the audio file to transcribe. + * @param format - The format of the result object. See {@link AudioResultFormat} for possible values. + * @param options - The options for this audio transcription request. + * @returns The audio transcription result in a format of your choice. + */ +export async function getAudioTranscription( + context: Client, + deploymentName: string, + fileContent: Uint8Array, + format: Format, + options?: GetAudioTranscriptionOptions +): Promise>; +// implementation +export async function getAudioTranscription( + context: Client, + deploymentName: string, + fileContent: Uint8Array, + formatOrOptions?: Format | GetAudioTranscriptionOptions, + inputOptions?: GetAudioTranscriptionOptions +): Promise> { + const options = + inputOptions ?? (typeof formatOrOptions === "string" ? {} : formatOrOptions ?? {}); + const response_format = typeof formatOrOptions === "string" ? formatOrOptions : undefined; + const { temperature, language, prompt, model, ...rest } = options; + const { body, status } = await context + .pathUnchecked("deployments/{deploymentId}/audio/transcriptions", deploymentName) + .post({ + body: { + file: createFile(fileContent), + ...(response_format && { response_format }), + ...(language && { language }), + ...(temperature !== undefined ? { temperature } : {}), + ...(prompt && { prompt }), + ...(model && { model }), + }, + ...rest, + contentType: "multipart/form-data", + }); + if (status !== "200") { + throw body.error; + } + return response_format !== "verbose_json" + ? body + : (renameKeysToCamelCase(body) as AudioResult); +} diff --git a/sdk/openai/openai/sources/customizations/api/policies/formDataPolicy.browser.ts b/sdk/openai/openai/sources/customizations/api/policies/formDataPolicy.browser.ts new file mode 100644 index 000000000000..e75b0a0a3f50 --- /dev/null +++ b/sdk/openai/openai/sources/customizations/api/policies/formDataPolicy.browser.ts @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { + PipelineRequest, + PipelinePolicy, + PipelineResponse, + SendRequest, +} from "@azure/core-rest-pipeline"; + +/** + * The programmatic identifier of the formDataPolicy. + */ +export const formDataPolicyName = "formDataWithFileUploadPolicy"; + +/** + * A policy that encodes FormData on the request into the body. + */ +export function formDataWithFileUploadPolicy(): PipelinePolicy { + return { + name: formDataPolicyName, + async sendRequest(request: PipelineRequest, next: SendRequest): Promise { + if (request.formData) { + const formData = request.formData; + const requestForm = new FormData(); + for (const formKey of Object.keys(formData)) { + const formValue = formData[formKey]; + if (Array.isArray(formValue)) { + for (const subValue of formValue) { + requestForm.append(formKey, subValue); + } + } else { + requestForm.append(formKey, formValue); + } + } + + request.body = requestForm; + request.formData = undefined; + const contentType = request.headers.get("Content-Type"); + if (contentType && contentType.indexOf("application/x-www-form-urlencoded") !== -1) { + request.body = new URLSearchParams(requestForm as any).toString(); + } else if (contentType && contentType.indexOf("multipart/form-data") !== -1) { + // browser will automatically apply a suitable content-type header + request.headers.delete("Content-Type"); + } + } + return next(request); + }, + }; +} + +export function createFile(data: Uint8Array | string): File { + return new File([data], "placeholder.wav"); +} diff --git a/sdk/openai/openai/sources/customizations/api/policies/formDataPolicy.ts b/sdk/openai/openai/sources/customizations/api/policies/formDataPolicy.ts new file mode 100644 index 000000000000..b23e98916852 --- /dev/null +++ b/sdk/openai/openai/sources/customizations/api/policies/formDataPolicy.ts @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import type { FormDataEncoder } from "../../../../node_modules/form-data-encoder/@type/index.d.ts"; +import { FormData, File } from "formdata-node"; +import { + FormDataMap, + PipelinePolicy, + PipelineRequest, + PipelineResponse, + SendRequest, +} from "@azure/core-rest-pipeline"; +import { Readable } from "node:stream"; + +/** + * The programmatic identifier of the formDataPolicy. + */ +export const formDataPolicyName = "formDataPolicyWithFileUpload"; + +/** + * A policy that encodes FormData on the request into the body. + */ +export function formDataWithFileUploadPolicy(): PipelinePolicy { + return { + name: formDataPolicyName, + async sendRequest(request: PipelineRequest, next: SendRequest): Promise { + if (request.formData) { + const contentType = request.headers.get("Content-Type"); + if (contentType && contentType.indexOf("application/x-www-form-urlencoded") !== -1) { + request.body = wwwFormUrlEncode(request.formData); + request.formData = undefined; + } else { + await prepareFormData(request.formData, request); + } + } + return next(request); + }, + }; +} + +function wwwFormUrlEncode(formData: FormDataMap): string { + const urlSearchParams = new URLSearchParams(); + for (const [key, value] of Object.entries(formData)) { + if (Array.isArray(value)) { + for (const subValue of value) { + urlSearchParams.append(key, subValue.toString()); + } + } else { + urlSearchParams.append(key, value.toString()); + } + } + return urlSearchParams.toString(); +} + +async function prepareFormData(formData: FormDataMap, request: PipelineRequest): Promise { + const requestForm = new FormData(); + for (const formKey of Object.keys(formData)) { + const formValue = formData[formKey]; + if (Array.isArray(formValue)) { + for (const subValue of formValue) { + requestForm.append(formKey, subValue); + } + } else { + requestForm.append(formKey, formValue); + } + } + + // This library doesn't define `type` entries in the exports section of its package.json. + // See https://github.com/microsoft/TypeScript/issues/52363 + const { FormDataEncoder } = await import("form-data-encoder" as any); + const encoder: FormDataEncoder = new FormDataEncoder(requestForm); + const body = Readable.from(encoder.encode()); + request.body = body; + request.formData = undefined; + const contentType = request.headers.get("Content-Type"); + if (contentType && contentType.indexOf("multipart/form-data") !== -1) { + request.headers.set("Content-Type", encoder.contentType); + } + const contentLength = encoder.contentLength; + if (contentLength !== undefined) { + request.headers.set("Content-Length", contentLength); + } +} + +export function createFile(data: Uint8Array | string): File { + return new File([data], "placeholder.wav"); +} diff --git a/sdk/openai/openai/sources/customizations/api/policies/nonAzure.ts b/sdk/openai/openai/sources/customizations/api/policies/nonAzure.ts new file mode 100644 index 000000000000..146af99926b5 --- /dev/null +++ b/sdk/openai/openai/sources/customizations/api/policies/nonAzure.ts @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { PipelinePolicy } from "@azure/core-rest-pipeline"; + +export function nonAzurePolicy(): PipelinePolicy { + const policy: PipelinePolicy = { + name: "openAiEndpoint", + sendRequest: (request, next) => { + const obj = new URL(request.url); + const parts = obj.pathname.split("/"); + switch (parts[parts.length - 1]) { + case "completions": + if (parts[parts.length - 2] === "chat") { + obj.pathname = `v1/chat/completions`; + } else { + obj.pathname = `/v1/completions`; + } + break; + case "embeddings": + obj.pathname = `/v1/embeddings`; + break; + case "generations:submit": + obj.pathname = `/v1/images/generations`; + break; + case "transcriptions": + obj.pathname = `/v1/audio/transcriptions`; + break; + case "translations": + obj.pathname = `/v1/audio/translations`; + break; + } + obj.searchParams.delete("api-version"); + request.url = obj.toString(); + return next(request); + }, + }; + return policy; +} diff --git a/sdk/openai/openai/sources/customizations/api/util.ts b/sdk/openai/openai/sources/customizations/api/util.ts index 7db142836d02..9ff76d57b95f 100644 --- a/sdk/openai/openai/sources/customizations/api/util.ts +++ b/sdk/openai/openai/sources/customizations/api/util.ts @@ -9,3 +9,29 @@ export function wrapError(f: () => T, message: string): T { throw new Error(message, { cause }); } } + +function tocamelCase(str: string): string { + return str.replace(/([_][a-z])/g, (group) => group.toUpperCase().replace("_", "")); +} + +/** + * Rename keys to camel case. + * @param obj - The object to rename keys to camel case + * @returns The object with keys renamed to camel case + */ +export function renameKeysToCamelCase(obj: Record): Record { + for (const key of Object.keys(obj)) { + const value = obj[key]; + const newKey = tocamelCase(key); + if (newKey !== key) { + delete obj[key]; + } + obj[newKey] = + typeof value === "object" + ? Array.isArray(value) + ? value.map((v) => renameKeysToCamelCase(v)) + : renameKeysToCamelCase(value) + : value; + } + return obj; +} diff --git a/sdk/openai/openai/sources/customizations/index.ts b/sdk/openai/openai/sources/customizations/index.ts index a432c9d8ddf5..468b511d7ad5 100644 --- a/sdk/openai/openai/sources/customizations/index.ts +++ b/sdk/openai/openai/sources/customizations/index.ts @@ -16,3 +16,4 @@ export { AzureKeyCredential } from "@azure/core-auth"; export { OpenAIClient } from "./OpenAIClient.js"; export { OpenAIKeyCredential } from "./OpenAIKeyCredential.js"; export { AzureExtensionsOptions, GetChatCompletionsOptions } from "./api/models.js"; +export * from "./models/audio.js"; diff --git a/sdk/openai/openai/sources/customizations/models/audio.ts b/sdk/openai/openai/sources/customizations/models/audio.ts new file mode 100644 index 000000000000..e669414fb8c2 --- /dev/null +++ b/sdk/openai/openai/sources/customizations/models/audio.ts @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { OperationOptions } from "@azure-rest/core-client"; + +export type AudioResultFormat = + /** This format will return an JSON structure containing a single \"text\" with the transcription. */ + | "json" + /** This format will return an JSON structure containing an enriched structure with the transcription. */ + | "verbose_json" + /** This will make the response return the transcription as plain/text. */ + | "text" + /** The transcription will be provided in SRT format (SubRip Text) in the form of plain/text. */ + | "srt" + /** The transcription will be provided in VTT format (Web Video Text Tracks) in the form of plain/text. */ + | "vtt"; + +/** Simple transcription response */ +export interface AudioResultSimpleJson { + /** Transcribed text. */ + text: string; +} + +/** Transcription response. */ +export interface AudioResultVerboseJson extends AudioResultSimpleJson { + /** Audio transcription task. */ + task: AudioTranscriptionTask; + /** Language detected in the source audio file. */ + language: string; + /** Duration. */ + duration: string; + /** Segments. */ + segments: AudioSegment[]; +} + +/** Audio transcription task type */ +/** "transcribe", "translate" */ +export type AudioTranscriptionTask = string; + +/** Transcription segment. */ +export interface AudioSegment { + /** Segment identifier. */ + id: number; + /** Segment start offset. */ + start: number; + /** Segment end offset. */ + end: number; + /** Segment text. */ + text: string; + /** Temperature. */ + temperature: number; + /** Average log probability. */ + averageLogProb: number; + /** Compression ratio. */ + compressionRatio: number; + /** Probability of 'no speech'. */ + noSpeechProb: number; + /** Tokens in this segment */ + tokens: number[]; + /** TODO */ + seek: number; +} + +export interface GetAudioTranscriptionOptions extends OperationOptions { + /** An optional text to guide the model's style or continue a previous audio segment. The prompt should match the audio language. */ + prompt?: string; + /** + * The sampling temperature, between 0 and 1. + * Higher values like 0.8 will make the output more random, while lower values like 0.2 will make it more focused and deterministic. + * If set to 0, the model will use log probability to automatically increase the temperature until certain thresholds are hit. + */ + temperature?: number; + /** The language of the input audio. Supplying the input language in ISO-639-1 format will improve accuracy and latency. */ + language?: string; + /** (non-Azure) ID of the model to use. Only whisper-1 is currently available. */ + model?: string; +} + +export interface GetAudioTranslationOptions extends OperationOptions { + /** An optional text to guide the model's style or continue a previous audio segment. The prompt should match the audio language. */ + prompt?: string; + /** + * The sampling temperature, between 0 and 1. + * Higher values like 0.8 will make the output more random, while lower values like 0.2 will make it more focused and deterministic. + * If set to 0, the model will use log probability to automatically increase the temperature until certain thresholds are hit. + */ + temperature?: number; + /** (non-Azure) ID of the model to use. Only whisper-1 is currently available. */ + model?: string; +} + +/** The type of the result of the transcription based on the requested response format */ +export type AudioResult = { + json: AudioResultSimpleJson; + verbose_json: AudioResultVerboseJson; + vtt: string; + srt: string; + text: string; +}[ResponseFormat]; diff --git a/sdk/openai/openai/src/OpenAIClient.ts b/sdk/openai/openai/src/OpenAIClient.ts index ea1a3e204b14..c5865d7bc406 100644 --- a/sdk/openai/openai/src/OpenAIClient.ts +++ b/sdk/openai/openai/src/OpenAIClient.ts @@ -10,7 +10,6 @@ */ import { KeyCredential, TokenCredential, isTokenCredential } from "@azure/core-auth"; -import { PipelinePolicy } from "@azure/core-rest-pipeline"; import { OpenAIClientOptions, OpenAIContext, @@ -35,6 +34,17 @@ import { ImageGenerationOptions, } from "./models/options.js"; import { GetChatCompletionsOptions } from "./api/models.js"; +import { + AudioResultFormat, + AudioResult, + GetAudioTranscriptionOptions, + GetAudioTranslationOptions, + AudioResultSimpleJson, +} from "./models/audio.js"; +import { nonAzurePolicy } from "./api/policies/nonAzure.js"; +import { formDataPolicyName } from "@azure/core-rest-pipeline"; +import { formDataWithFileUploadPolicy } from "./api/policies/formDataPolicy.js"; +import { getAudioTranscription, getAudioTranslation } from "./api/operations.js"; export { OpenAIClientOptions } from "./api/OpenAIContext.js"; @@ -105,11 +115,13 @@ export class OpenAIClient { ...(opts.additionalPolicies ?? []), { position: "perCall", - policy: getPolicy(), + policy: nonAzurePolicy(), }, ], }), }); + this._client.pipeline.removePolicy({ name: formDataPolicyName }); + this._client.pipeline.addPolicy(formDataWithFileUploadPolicy()); } /** @@ -205,6 +217,102 @@ export class OpenAIClient { return getImages(this._client, prompt, options); } + /** + * Returns the transcription of an audio file in a simple JSON format. + * @param deploymentName - The name of the model deployment (when using Azure OpenAI) or model name (when using non-Azure OpenAI) to use for this request. + * @param fileContent - The content of the audio file to transcribe. + * @param options - The options for this audio transcription request. + * @returns The audio transcription result in a simple JSON format. + */ + async getAudioTranscription( + deploymentName: string, + fileContent: Uint8Array, + options?: GetAudioTranscriptionOptions + ): Promise; + /** + * Returns the transcription of an audio file. + * @param deploymentName - The name of the model deployment (when using Azure OpenAI) or model name (when using non-Azure OpenAI) to use for this request. + * @param fileContent - The content of the audio file to transcribe. + * @param format - The format of the result object. See {@link AudioResultFormat} for possible values. + * @param options - The options for this audio transcription request. + * @returns The audio transcription result in a format of your choice. + */ + async getAudioTranscription( + deploymentName: string, + fileContent: Uint8Array, + format: Format, + options?: GetAudioTranscriptionOptions + ): Promise>; + // implementation + async getAudioTranscription( + deploymentName: string, + fileContent: Uint8Array, + formatOrOptions?: Format | GetAudioTranscriptionOptions, + inputOptions?: GetAudioTranscriptionOptions + ): Promise> { + const options = + inputOptions ?? (typeof formatOrOptions === "string" ? {} : formatOrOptions ?? {}); + const response_format = typeof formatOrOptions === "string" ? formatOrOptions : undefined; + this.setModel(deploymentName, options); + if (response_format === undefined) { + return getAudioTranscription(this._client, deploymentName, fileContent, options) as Promise< + AudioResult + >; + } + return getAudioTranscription( + this._client, + deploymentName, + fileContent, + response_format, + options + ); + } + + /** + * Returns the translation of an audio file. + * @param deploymentName - The name of the model deployment (when using Azure OpenAI) or model name (when using non-Azure OpenAI) to use for this request. + * @param fileContent - The content of the audio file to translate. + * @param options - The options for this audio translation request. + * @returns The audio translation result. + */ + async getAudioTranslation( + deploymentName: string, + fileContent: Uint8Array, + options?: GetAudioTranslationOptions + ): Promise; + /** + * Returns the translation of an audio file. + * @param deploymentName - The name of the model deployment (when using Azure OpenAI) or model name (when using non-Azure OpenAI) to use for this request. + * @param fileContent - The content of the audio file to translate. + * @param format - The format of the result object. See {@link AudioResultFormat} for possible values. + * @param options - The options for this audio translation request. + * @returns The audio translation result. + */ + async getAudioTranslation( + deploymentName: string, + fileContent: Uint8Array, + format: Format, + options?: GetAudioTranslationOptions + ): Promise>; + // implementation + async getAudioTranslation( + deploymentName: string, + fileContent: Uint8Array, + formatOrOptions?: Format | GetAudioTranslationOptions, + inputOptions?: GetAudioTranslationOptions + ): Promise> { + const options = + inputOptions ?? (typeof formatOrOptions === "string" ? {} : formatOrOptions ?? {}); + const response_format = typeof formatOrOptions === "string" ? formatOrOptions : undefined; + this.setModel(deploymentName, options); + if (response_format === undefined) { + return getAudioTranslation(this._client, deploymentName, fileContent, options) as Promise< + AudioResult + >; + } + return getAudioTranslation(this._client, deploymentName, fileContent, response_format, options); + } + private setModel(model: string, options: { model?: string }): void { if (!this._isAzure) { options.model = model; @@ -219,31 +327,3 @@ function createOpenAIEndpoint(version: number): string { function isCred(cred: Record): cred is TokenCredential | KeyCredential { return isTokenCredential(cred) || cred.key !== undefined; } - -function getPolicy(): PipelinePolicy { - const policy: PipelinePolicy = { - name: "openAiEndpoint", - sendRequest: (request, next) => { - const obj = new URL(request.url); - const parts = obj.pathname.split("/"); - switch (parts[parts.length - 1]) { - case "completions": - if (parts[parts.length - 2] === "chat") { - obj.pathname = `/${parts[1]}/chat/completions`; - } else { - obj.pathname = `/${parts[1]}/completions`; - } - break; - case "embeddings": - obj.pathname = `/${parts[1]}/embeddings`; - break; - case "generations:submit": - obj.pathname = `/${parts[1]}/images/generations`; - } - obj.searchParams.delete("api-version"); - request.url = obj.toString(); - return next(request); - }, - }; - return policy; -} diff --git a/sdk/openai/openai/src/api/getSSEs.browser.ts b/sdk/openai/openai/src/api/getSSEs.browser.ts index ff5ec5c5aa4f..87f8b8ecb0eb 100644 --- a/sdk/openai/openai/src/api/getSSEs.browser.ts +++ b/sdk/openai/openai/src/api/getSSEs.browser.ts @@ -11,6 +11,7 @@ import { StreamableMethod } from "@azure-rest/core-client"; import { EventMessage, iterateSseStream } from "@azure/core-sse"; +import { wrapError } from "./util.js"; export async function getSSEs( response: StreamableMethod @@ -22,7 +23,45 @@ export async function getSSEs( async function getStream( response: StreamableMethod ): Promise> { - const stream = (await response.asBrowserStream()).body; - if (!stream) throw new Error("No stream found in response. Did you enable the stream option?"); - return stream; + const { body, status } = await response.asBrowserStream(); + if (status !== "200" && body !== undefined) { + const text = await streamToText(body); + throw wrapError(() => JSON.parse(text).error, "Error parsing response body"); + } + if (!body) throw new Error("No stream found in response. Did you enable the stream option?"); + return body; +} + +async function streamToText(stream: ReadableStream): Promise { + const reader = stream.getReader(); + const buffers: Uint8Array[] = []; + let length = 0; + try { + // eslint-disable-next-line no-constant-condition + while (true) { + const { value, done } = await reader.read(); + if (done) { + return new TextDecoder().decode(concatBuffers(buffers, length)); + } + length += value.length; + buffers.push(value); + } + } finally { + reader.releaseLock(); + } +} + +function getBuffersLength(buffers: Uint8Array[]): number { + return buffers.reduce((acc, curr) => acc + curr.length, 0); +} + +function concatBuffers(buffers: Uint8Array[], len?: number): Uint8Array { + const length = len ?? getBuffersLength(buffers); + const res = new Uint8Array(length); + for (let i = 0, pos = 0; i < buffers.length; i++) { + const buffer = buffers[i]; + res.set(buffer, pos); + pos += buffer.length; + } + return res; } diff --git a/sdk/openai/openai/src/api/getSSEs.ts b/sdk/openai/openai/src/api/getSSEs.ts index 85d5a6cc13d4..3326063f7492 100644 --- a/sdk/openai/openai/src/api/getSSEs.ts +++ b/sdk/openai/openai/src/api/getSSEs.ts @@ -11,6 +11,8 @@ import { StreamableMethod } from "@azure-rest/core-client"; import { EventMessage, iterateSseStream } from "@azure/core-sse"; +import { RestError } from "@azure/core-rest-pipeline"; +import { wrapError } from "./util.js"; export async function getSSEs( response: StreamableMethod @@ -22,7 +24,39 @@ export async function getSSEs( async function getStream( response: StreamableMethod ): Promise> { - const stream = (await response.asNodeStream()).body; - if (!stream) throw new Error("No stream found in response. Did you enable the stream option?"); - return stream as AsyncIterable; + const { body, status } = await response.asNodeStream(); + if (status !== "200" && body !== undefined) { + const text = await streamToText(body); + throw wrapError(() => JSON.parse(text).error, "Error parsing response body"); + } + if (!body) throw new Error("No stream found in response. Did you enable the stream option?"); + return body as AsyncIterable; +} + +function streamToText(stream: NodeJS.ReadableStream): Promise { + return new Promise((resolve, reject) => { + const buffer: Buffer[] = []; + + stream.on("data", (chunk) => { + if (Buffer.isBuffer(chunk)) { + buffer.push(chunk); + } else { + buffer.push(Buffer.from(chunk)); + } + }); + stream.on("end", () => { + resolve(Buffer.concat(buffer).toString("utf8")); + }); + stream.on("error", (e) => { + if (e && e?.name === "AbortError") { + reject(e); + } else { + reject( + new RestError(`Error reading response as text: ${e.message}`, { + code: RestError.PARSE_ERROR, + }) + ); + } + }); + }); } diff --git a/sdk/openai/openai/src/api/index.ts b/sdk/openai/openai/src/api/index.ts index b51d0bd2a683..e78fdc76c31e 100644 --- a/sdk/openai/openai/src/api/index.ts +++ b/sdk/openai/openai/src/api/index.ts @@ -18,4 +18,6 @@ export { listChatCompletions, listCompletions, getImages, + getAudioTranscription, + getAudioTranslation, } from "./operations.js"; diff --git a/sdk/openai/openai/src/api/operations.ts b/sdk/openai/openai/src/api/operations.ts index 64958ff377cb..5fc425cc0ddb 100644 --- a/sdk/openai/openai/src/api/operations.ts +++ b/sdk/openai/openai/src/api/operations.ts @@ -46,6 +46,15 @@ import { import { getChatCompletionsResult, getCompletionsResult } from "./deserializers.js"; import { getOaiSSEs } from "./oaiSse.js"; import { GetChatCompletionsOptions } from "./models.js"; +import { + AudioResult, + AudioResultFormat, + AudioResultSimpleJson, + GetAudioTranscriptionOptions, + GetAudioTranslationOptions, +} from "../models/audio.js"; +import { createFile } from "./policies/formDataPolicy.js"; +import { renameKeysToCamelCase } from "./util.js"; export function _getEmbeddingsSend( context: Client, @@ -650,3 +659,130 @@ function _getChatCompletionsSendX( }) : _getChatCompletionsSend(context, messages, deploymentName, options); } + +/** + * Returns the translation of an audio file. + * @param context - The context containing the client to use for this request. + * @param deploymentName - The name of the model deployment (when using Azure OpenAI) or model name (when using non-Azure OpenAI) to use for this request. + * @param fileContent - The content of the audio file to translate. + * @param options - The options for this audio translation request. + * @returns The audio translation result. + */ +export async function getAudioTranslation( + context: Client, + deploymentName: string, + fileContent: Uint8Array, + options?: GetAudioTranslationOptions +): Promise; +/** + * Returns the translation of an audio file. + * @param context - The context containing the client to use for this request. + * @param deploymentName - The name of the model deployment (when using Azure OpenAI) or model name (when using non-Azure OpenAI) to use for this request. + * @param fileContent - The content of the audio file to translate. + * @param format - The format of the result object. See {@link AudioResultFormat} for possible values. + * @param options - The options for this audio translation request. + * @returns The audio translation result. + */ +export async function getAudioTranslation( + context: Client, + deploymentName: string, + fileContent: Uint8Array, + format: Format, + options?: GetAudioTranslationOptions +): Promise>; +// implementation +export async function getAudioTranslation( + context: Client, + deploymentName: string, + fileContent: Uint8Array, + formatOrOptions?: Format | GetAudioTranslationOptions, + inputOptions?: GetAudioTranslationOptions +): Promise> { + const options = + inputOptions ?? (typeof formatOrOptions === "string" ? {} : formatOrOptions ?? {}); + const response_format = typeof formatOrOptions === "string" ? formatOrOptions : undefined; + const { temperature, prompt, model, ...rest } = options; + const { body, status } = await context + .pathUnchecked("deployments/{deploymentId}/audio/translations", deploymentName) + .post({ + body: { + file: createFile(fileContent), + ...(response_format && { response_format }), + ...(temperature !== undefined ? { temperature } : {}), + ...(prompt && { prompt }), + ...(model && { model }), + }, + ...rest, + contentType: "multipart/form-data", + }); + if (status !== "200") { + throw body.error; + } + return response_format !== "verbose_json" + ? body + : (renameKeysToCamelCase(body) as AudioResult); +} + +/** + * Returns the transcription of an audio file in a simple JSON format. + * @param context - The context containing the client to use for this request. + * @param deploymentName - The name of the model deployment (when using Azure OpenAI) or model name (when using non-Azure OpenAI) to use for this request. + * @param fileContent - The content of the audio file to transcribe. + * @param options - The options for this audio transcription request. + * @returns The audio transcription result in a simple JSON format. + */ +export async function getAudioTranscription( + context: Client, + deploymentName: string, + fileContent: Uint8Array, + options?: GetAudioTranscriptionOptions +): Promise; +/** + * Returns the transcription of an audio file. + * @param context - The context containing the client to use for this request. + * @param deploymentName - The name of the model deployment (when using Azure OpenAI) or model name (when using non-Azure OpenAI) to use for this request. + * @param fileContent - The content of the audio file to transcribe. + * @param format - The format of the result object. See {@link AudioResultFormat} for possible values. + * @param options - The options for this audio transcription request. + * @returns The audio transcription result in a format of your choice. + */ +export async function getAudioTranscription( + context: Client, + deploymentName: string, + fileContent: Uint8Array, + format: Format, + options?: GetAudioTranscriptionOptions +): Promise>; +// implementation +export async function getAudioTranscription( + context: Client, + deploymentName: string, + fileContent: Uint8Array, + formatOrOptions?: Format | GetAudioTranscriptionOptions, + inputOptions?: GetAudioTranscriptionOptions +): Promise> { + const options = + inputOptions ?? (typeof formatOrOptions === "string" ? {} : formatOrOptions ?? {}); + const response_format = typeof formatOrOptions === "string" ? formatOrOptions : undefined; + const { temperature, language, prompt, model, ...rest } = options; + const { body, status } = await context + .pathUnchecked("deployments/{deploymentId}/audio/transcriptions", deploymentName) + .post({ + body: { + file: createFile(fileContent), + ...(response_format && { response_format }), + ...(language && { language }), + ...(temperature !== undefined ? { temperature } : {}), + ...(prompt && { prompt }), + ...(model && { model }), + }, + ...rest, + contentType: "multipart/form-data", + }); + if (status !== "200") { + throw body.error; + } + return response_format !== "verbose_json" + ? body + : (renameKeysToCamelCase(body) as AudioResult); +} diff --git a/sdk/openai/openai/src/api/policies/formDataPolicy.browser.ts b/sdk/openai/openai/src/api/policies/formDataPolicy.browser.ts new file mode 100644 index 000000000000..d9391e877d03 --- /dev/null +++ b/sdk/openai/openai/src/api/policies/formDataPolicy.browser.ts @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * THIS IS AN AUTO-GENERATED FILE - DO NOT EDIT! + * + * Any changes you make here may be lost. + * + * If you need to make changes, please do so in the original source file, \{project-root\}/sources/custom + */ + +import { + PipelineRequest, + PipelinePolicy, + PipelineResponse, + SendRequest, +} from "@azure/core-rest-pipeline"; + +/** + * The programmatic identifier of the formDataPolicy. + */ +export const formDataPolicyName = "formDataWithFileUploadPolicy"; + +/** + * A policy that encodes FormData on the request into the body. + */ +export function formDataWithFileUploadPolicy(): PipelinePolicy { + return { + name: formDataPolicyName, + async sendRequest(request: PipelineRequest, next: SendRequest): Promise { + if (request.formData) { + const formData = request.formData; + const requestForm = new FormData(); + for (const formKey of Object.keys(formData)) { + const formValue = formData[formKey]; + if (Array.isArray(formValue)) { + for (const subValue of formValue) { + requestForm.append(formKey, subValue); + } + } else { + requestForm.append(formKey, formValue); + } + } + + request.body = requestForm; + request.formData = undefined; + const contentType = request.headers.get("Content-Type"); + if (contentType && contentType.indexOf("application/x-www-form-urlencoded") !== -1) { + request.body = new URLSearchParams(requestForm as any).toString(); + } else if (contentType && contentType.indexOf("multipart/form-data") !== -1) { + // browser will automatically apply a suitable content-type header + request.headers.delete("Content-Type"); + } + } + return next(request); + }, + }; +} + +export function createFile(data: Uint8Array | string): File { + return new File([data], "placeholder.wav"); +} diff --git a/sdk/openai/openai/src/api/policies/formDataPolicy.ts b/sdk/openai/openai/src/api/policies/formDataPolicy.ts new file mode 100644 index 000000000000..82aff3642f45 --- /dev/null +++ b/sdk/openai/openai/src/api/policies/formDataPolicy.ts @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * THIS IS AN AUTO-GENERATED FILE - DO NOT EDIT! + * + * Any changes you make here may be lost. + * + * If you need to make changes, please do so in the original source file, \{project-root\}/sources/custom + */ + +import type { FormDataEncoder } from "../../../node_modules/form-data-encoder/@type/index.d.ts"; +import { FormData, File } from "formdata-node"; +import { + FormDataMap, + PipelinePolicy, + PipelineRequest, + PipelineResponse, + SendRequest, +} from "@azure/core-rest-pipeline"; +import { Readable } from "node:stream"; + +/** + * The programmatic identifier of the formDataPolicy. + */ +export const formDataPolicyName = "formDataPolicyWithFileUpload"; + +/** + * A policy that encodes FormData on the request into the body. + */ +export function formDataWithFileUploadPolicy(boundary?: string): PipelinePolicy { + return { + name: formDataPolicyName, + async sendRequest(request: PipelineRequest, next: SendRequest): Promise { + if (request.formData) { + const contentType = request.headers.get("Content-Type"); + if (contentType && contentType.indexOf("application/x-www-form-urlencoded") !== -1) { + request.body = wwwFormUrlEncode(request.formData); + request.formData = undefined; + } else { + await prepareFormData(request.formData, request, boundary); + } + } + return next(request); + }, + }; +} + +function wwwFormUrlEncode(formData: FormDataMap): string { + const urlSearchParams = new URLSearchParams(); + for (const [key, value] of Object.entries(formData)) { + if (Array.isArray(value)) { + for (const subValue of value) { + urlSearchParams.append(key, subValue.toString()); + } + } else { + urlSearchParams.append(key, value.toString()); + } + } + return urlSearchParams.toString(); +} + +async function prepareFormData( + formData: FormDataMap, + request: PipelineRequest, + boundary?: string +): Promise { + const requestForm = new FormData(); + for (const formKey of Object.keys(formData)) { + const formValue = formData[formKey]; + if (Array.isArray(formValue)) { + for (const subValue of formValue) { + requestForm.append(formKey, subValue); + } + } else { + requestForm.append(formKey, formValue); + } + } + // This library doesn't define `type` entries in the exports section of its package.json. + // See https://github.com/microsoft/TypeScript/issues/52363 + const { FormDataEncoder } = await import("form-data-encoder" as any); + + const encoder: FormDataEncoder = boundary + ? new FormDataEncoder(requestForm, boundary) + : new FormDataEncoder(requestForm); + const body = Readable.from(encoder.encode()); + request.body = body; + request.formData = undefined; + const contentType = request.headers.get("Content-Type"); + if (contentType && contentType.indexOf("multipart/form-data") !== -1) { + request.headers.set("Content-Type", encoder.contentType); + } + const contentLength = encoder.contentLength; + if (contentLength !== undefined) { + request.headers.set("Content-Length", contentLength); + } +} + +export function createFile(data: Uint8Array | string): File { + return new File([data], "placeholder.wav"); +} diff --git a/sdk/openai/openai/src/api/policies/nonAzure.ts b/sdk/openai/openai/src/api/policies/nonAzure.ts new file mode 100644 index 000000000000..c3a2436b7693 --- /dev/null +++ b/sdk/openai/openai/src/api/policies/nonAzure.ts @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * THIS IS AN AUTO-GENERATED FILE - DO NOT EDIT! + * + * Any changes you make here may be lost. + * + * If you need to make changes, please do so in the original source file, \{project-root\}/sources/custom + */ + +import { PipelinePolicy } from "@azure/core-rest-pipeline"; + +export function nonAzurePolicy(): PipelinePolicy { + const policy: PipelinePolicy = { + name: "openAiEndpoint", + sendRequest: (request, next) => { + const obj = new URL(request.url); + const parts = obj.pathname.split("/"); + switch (parts[parts.length - 1]) { + case "completions": + if (parts[parts.length - 2] === "chat") { + obj.pathname = `${parts[1]}/chat/completions`; + } else { + obj.pathname = `${parts[1]}/completions`; + } + break; + case "embeddings": + obj.pathname = `${parts[1]}/embeddings`; + break; + case "generations:submit": + obj.pathname = `${parts[1]}/images/generations`; + break; + case "transcriptions": + obj.pathname = `${parts[1]}/audio/transcriptions`; + break; + case "translations": + obj.pathname = `${parts[1]}/audio/translations`; + break; + } + obj.searchParams.delete("api-version"); + request.url = obj.toString(); + return next(request); + }, + }; + return policy; +} diff --git a/sdk/openai/openai/src/api/util.ts b/sdk/openai/openai/src/api/util.ts index 6ad17fc309c5..1208afd29df8 100644 --- a/sdk/openai/openai/src/api/util.ts +++ b/sdk/openai/openai/src/api/util.ts @@ -8,11 +8,38 @@ * * If you need to make changes, please do so in the original source file, \{project-root\}/sources/custom */ + export function wrapError(f: () => T, message: string): T { try { const result = f(); return result; } catch (cause) { - throw new Error(message, { cause }); + throw new Error(`${message}: ${cause}`, { cause }); + } +} + +function tocamelCase(str: string): string { + return str.replace(/([_][a-z])/g, (group) => group.toUpperCase().replace("_", "")); +} + +/** + * Rename keys to camel case. + * @param obj - The object to rename keys to camel case + * @returns The object with keys renamed to camel case + */ +export function renameKeysToCamelCase(obj: Record): Record { + for (const key of Object.keys(obj)) { + const value = obj[key]; + const newKey = tocamelCase(key); + if (newKey !== key) { + delete obj[key]; + } + obj[newKey] = + typeof value === "object" + ? Array.isArray(value) + ? value.map((v) => renameKeysToCamelCase(v)) + : renameKeysToCamelCase(value) + : value; } + return obj; } diff --git a/sdk/openai/openai/src/index.ts b/sdk/openai/openai/src/index.ts index 2678d474d2b4..f4c3284c393f 100644 --- a/sdk/openai/openai/src/index.ts +++ b/sdk/openai/openai/src/index.ts @@ -16,3 +16,4 @@ export { AzureKeyCredential } from "@azure/core-auth"; export { OpenAIKeyCredential } from "./OpenAIKeyCredential.js"; export { OpenAIClient, OpenAIClientOptions } from "./OpenAIClient.js"; export * from "./models/index.js"; +export * from "./models/audio.js"; diff --git a/sdk/openai/openai/src/models/audio.ts b/sdk/openai/openai/src/models/audio.ts new file mode 100644 index 000000000000..815956785ba0 --- /dev/null +++ b/sdk/openai/openai/src/models/audio.ts @@ -0,0 +1,110 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * THIS IS AN AUTO-GENERATED FILE - DO NOT EDIT! + * + * Any changes you make here may be lost. + * + * If you need to make changes, please do so in the original source file, \{project-root\}/sources/custom + */ + +import { OperationOptions } from "@azure-rest/core-client"; + +/** The result format of an audio task */ +export type AudioResultFormat = + /** This format will return an JSON structure containing a single \"text\" with the transcription. */ + | "json" + /** This format will return an JSON structure containing an enriched structure with the transcription. */ + | "verbose_json" + /** This will make the response return the transcription as plain/text. */ + | "text" + /** The transcription will be provided in SRT format (SubRip Text) in the form of plain/text. */ + | "srt" + /** The transcription will be provided in VTT format (Web Video Text Tracks) in the form of plain/text. */ + | "vtt"; + +/** The result of an audio task in a simple JSON format */ +export interface AudioResultSimpleJson { + /** Transcribed text. */ + text: string; +} + +/** Transcription response. */ +export interface AudioResultVerboseJson extends AudioResultSimpleJson { + /** Audio transcription task. */ + task: AudioTranscriptionTask; + /** Language detected in the source audio file. */ + language: string; + /** Duration. */ + duration: number; + /** Segments. */ + segments: AudioSegment[]; +} + +/** Audio transcription task type */ +/** "transcribe", "translate" */ +export type AudioTranscriptionTask = string; + +/** Transcription segment. */ +export interface AudioSegment { + /** Segment identifier. */ + id: number; + /** Segment start offset. */ + start: number; + /** Segment end offset. */ + end: number; + /** Segment text. */ + text: string; + /** Temperature. */ + temperature: number; + /** Average log probability. */ + avgLogprob: number; + /** Compression ratio. */ + compressionRatio: number; + /** Probability of 'no speech'. */ + noSpeechProb: number; + /** Tokens in this segment */ + tokens: number[]; + /** TODO */ + seek: number; +} + +/** The options for an audio transcription request */ +export interface GetAudioTranscriptionOptions extends OperationOptions { + /** An optional text to guide the model's style or continue a previous audio segment. The prompt should match the audio language. */ + prompt?: string; + /** + * The sampling temperature, between 0 and 1. + * Higher values like 0.8 will make the output more random, while lower values like 0.2 will make it more focused and deterministic. + * If set to 0, the model will use log probability to automatically increase the temperature until certain thresholds are hit. + */ + temperature?: number; + /** The language of the input audio. Supplying the input language in ISO-639-1 format will improve accuracy and latency. */ + language?: string; + /** (non-Azure) ID of the model to use. Only whisper-1 is currently available. */ + model?: string; +} + +/** The options for an audio translation request */ +export interface GetAudioTranslationOptions extends OperationOptions { + /** An optional text to guide the model's style or continue a previous audio segment. The prompt should match the audio language. */ + prompt?: string; + /** + * The sampling temperature, between 0 and 1. + * Higher values like 0.8 will make the output more random, while lower values like 0.2 will make it more focused and deterministic. + * If set to 0, the model will use log probability to automatically increase the temperature until certain thresholds are hit. + */ + temperature?: number; + /** (non-Azure) ID of the model to use. Only whisper-1 is currently available. */ + model?: string; +} + +/** The type of the result of the transcription based on the requested response format */ +export type AudioResult = { + json: AudioResultSimpleJson; + verbose_json: AudioResultVerboseJson; + vtt: string; + srt: string; + text: string; +}[ResponseFormat]; diff --git a/sdk/openai/openai/src/rest/openAIClient.ts b/sdk/openai/openai/src/rest/openAIClient.ts index fd558b187ba2..3652bb34f4c7 100644 --- a/sdk/openai/openai/src/rest/openAIClient.ts +++ b/sdk/openai/openai/src/rest/openAIClient.ts @@ -27,7 +27,7 @@ export default function createClient( options: ClientOptions = {} ): OpenAIContext { const baseUrl = options.baseUrl ?? `${endpoint}/openai`; - options.apiVersion = options.apiVersion ?? "2023-08-01-preview"; + options.apiVersion = options.apiVersion ?? "2023-09-01-preview"; options = { ...options, credentials: { diff --git a/sdk/openai/openai/test/public/node/whisper.spec.ts b/sdk/openai/openai/test/public/node/whisper.spec.ts new file mode 100644 index 000000000000..fb0661d601f6 --- /dev/null +++ b/sdk/openai/openai/test/public/node/whisper.spec.ts @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { Recorder } from "@azure-tools/test-recorder"; +import { matrix } from "@azure/test-utils"; +import { Context } from "mocha"; +import { AuthMethod, createClient, startRecorder } from "../utils/recordedClient.js"; +import { OpenAIClient } from "../../../src/index.js"; +import * as fs from "fs/promises"; +import { AudioResultFormat } from "../../../src/models/audio.js"; +import { + formDataPolicyName, + formDataWithFileUploadPolicy, +} from "../../../src/api/policies/formDataPolicy.js"; +import { assertAudioResult } from "../utils/asserts.js"; + +function getModel(authMethod: AuthMethod): string { + return authMethod === "OpenAIKey" ? "whisper-1" : "whisper-deployment"; +} + +describe("OpenAI", function () { + matrix([["AzureAPIKey", "OpenAIKey"]] as const, async function (authMethod: AuthMethod) { + describe(`[${authMethod}] Client`, () => { + let recorder: Recorder; + let client: OpenAIClient; + + beforeEach(async function (this: Context) { + recorder = await startRecorder(this.currentTest); + client = createClient(authMethod, { recorder }); + client["_client"].pipeline.removePolicy({ name: formDataPolicyName }); + client["_client"].pipeline.addPolicy(formDataWithFileUploadPolicy("6ceck6po4ai0tb2u")); + }); + + afterEach(async function () { + if (recorder) { + await recorder.stop(); + } + }); + + matrix( + [ + ["json", "verbose_json", "srt", "vtt", "text"], + ["m4a", "mp3", "wav", "ogg", "flac", "webm", "mp4", "mpeg", "oga", "mpga"], + ] as const, + async function (format: AudioResultFormat, extension: string) { + describe("getAudioTranscription", function () { + it(`returns ${format} transcription for ${extension} files`, async function () { + const file = await fs.readFile(`./assets/audio/countdown.${extension}`); + const res = await client.getAudioTranscription(getModel(authMethod), file, format); + assertAudioResult(format, res); + }); + }); + + describe("getAudioTranslation", function () { + it(`returns ${format} translation for ${extension} files`, async function () { + const file = await fs.readFile(`./assets/audio/countdown.${extension}`); + const res = await client.getAudioTranslation(getModel(authMethod), file, format); + assertAudioResult(format, res); + }); + }); + } + ); + }); + }); +}); diff --git a/sdk/openai/openai/test/public/openai.spec.ts b/sdk/openai/openai/test/public/openai.spec.ts index 16b4f8fef36c..dbc5d798520f 100644 --- a/sdk/openai/openai/test/public/openai.spec.ts +++ b/sdk/openai/openai/test/public/openai.spec.ts @@ -7,19 +7,19 @@ import { Context } from "mocha"; import { AuthMethod, createClient, startRecorder } from "./utils/recordedClient.js"; import { assertChatCompletions, - assertChatCompletionsStream, + assertChatCompletionsList, assertCompletions, assertCompletionsStream, } from "./utils/asserts.js"; import { + bufferAsyncIterable, getDeployments, getModels, getSucceeded, sendRequestWithRecorder, updateWithSucceeded, - withDeployment, + withDeployments, } from "./utils/utils.js"; -import { logger } from "./utils/logger.js"; import { createHttpHeaders, createPipelineRequest } from "@azure/core-rest-pipeline"; import { getImageDimensions } from "./utils/getImageDimensions.js"; import { OpenAIClient, ImageLocation } from "../../src/index.js"; @@ -54,10 +54,10 @@ describe("OpenAI", function () { describe("getCompletions", function () { it("returns completions across all models", async function () { const prompt = ["What is Azure OpenAI?"]; - await withDeployment( + await withDeployments( authMethod === "OpenAIKey" ? models : deployments, - async (deploymentName) => - client.getCompletions(deploymentName, prompt).then(assertCompletions) + async (deploymentName) => client.getCompletions(deploymentName, prompt), + assertCompletions ); }); }); @@ -183,7 +183,7 @@ describe("OpenAI", function () { describe("getChatCompletions", function () { it("returns completions across all models", async function () { updateWithSucceeded( - await withDeployment( + await withDeployments( getSucceeded( authMethod, deployments, @@ -191,10 +191,8 @@ describe("OpenAI", function () { chatCompletionDeployments, chatCompletionModels ), - async (deploymentName) => - client - .getChatCompletions(deploymentName, pirateMessages) - .then(assertChatCompletions) + async (deploymentName) => client.getChatCompletions(deploymentName, pirateMessages), + assertChatCompletions ), chatCompletionDeployments, chatCompletionModels, @@ -204,7 +202,7 @@ describe("OpenAI", function () { it("calls functions", async function () { updateWithSucceeded( - await withDeployment( + await withDeployments( getSucceeded( authMethod, deployments, @@ -213,11 +211,10 @@ describe("OpenAI", function () { chatCompletionModels ), async (deploymentName) => - client - .getChatCompletions(deploymentName, weatherMessages, { - functions: [getCurrentWeather], - }) - .then((c) => assertChatCompletions(c, { functions: true })) + client.getChatCompletions(deploymentName, weatherMessages, { + functions: [getCurrentWeather], + }), + (c) => assertChatCompletions(c, { functions: true }) ), chatCompletionDeployments, chatCompletionModels, @@ -225,12 +222,12 @@ describe("OpenAI", function () { ); }); - it("bring your own data", async function (this: Context) { + it.skip("bring your own data", async function (this: Context) { if (authMethod === "OpenAIKey") { this.skip(); } updateWithSucceeded( - await withDeployment( + await withDeployments( getSucceeded( authMethod, deployments, @@ -239,22 +236,21 @@ describe("OpenAI", function () { chatCompletionModels ), async (deploymentName) => - client - .getChatCompletions(deploymentName, byodMessages, { - azureExtensionOptions: { - extensions: [ - { - type: "AzureCognitiveSearch", - parameters: { - endpoint: assertEnvironmentVariable("AZURE_SEARCH_ENDPOINT"), - key: assertEnvironmentVariable("AZURE_SEARCH_KEY"), - indexName: assertEnvironmentVariable("AZURE_SEARCH_INDEX"), - }, + client.getChatCompletions(deploymentName, byodMessages, { + azureExtensionOptions: { + extensions: [ + { + type: "AzureCognitiveSearch", + parameters: { + endpoint: assertEnvironmentVariable("AZURE_SEARCH_ENDPOINT"), + key: assertEnvironmentVariable("AZURE_SEARCH_KEY"), + indexName: assertEnvironmentVariable("AZURE_SEARCH_INDEX"), }, - ], - }, - }) - .then(assertChatCompletions) + }, + ], + }, + }), + assertChatCompletions ), chatCompletionDeployments, chatCompletionModels, @@ -266,7 +262,7 @@ describe("OpenAI", function () { describe("listChatCompletions", function () { it("returns completions across all models", async function () { updateWithSucceeded( - await withDeployment( + await withDeployments( getSucceeded( authMethod, deployments, @@ -274,19 +270,17 @@ describe("OpenAI", function () { chatCompletionDeployments, chatCompletionModels ), - async (deploymentName) => { - const count = await assertChatCompletionsStream( - client.listChatCompletions(deploymentName, pirateMessages), - { - // The API returns an empty choice in the first event for some - // reason. This should be fixed in the API. - allowEmptyChoices: true, - } - ); - if (count === 0) { - logger.warning(`No completions returned for ${deploymentName}`); - } - } + async (deploymentName) => + bufferAsyncIterable(client.listChatCompletions(deploymentName, pirateMessages)), + async (res) => + assertChatCompletionsList(res, { + // The API returns an empty choice in the first event for some + // reason. This should be fixed in the API. + allowEmptyChoices: true, + // The API returns an empty ID in the first event for some + // reason. This should be fixed in the API. + allowEmptyId: true, + }) ), chatCompletionDeployments, chatCompletionModels, @@ -296,7 +290,7 @@ describe("OpenAI", function () { it("calls functions", async function () { updateWithSucceeded( - await withDeployment( + await withDeployments( getSucceeded( authMethod, deployments, @@ -304,22 +298,19 @@ describe("OpenAI", function () { chatCompletionDeployments, chatCompletionModels ), - async (deploymentName) => { - const count = await assertChatCompletionsStream( + async (deploymentName) => + bufferAsyncIterable( client.listChatCompletions(deploymentName, weatherMessages, { functions: [getCurrentWeather], - }), - { - functions: true, - // The API returns an empty choice in the first event for some - // reason. This should be fixed in the API. - allowEmptyChoices: true, - } - ); - if (count === 0) { - logger.warning(`No completions returned for ${deploymentName}`); - } - } + }) + ), + (res) => + assertChatCompletionsList(res, { + functions: true, + // The API returns an empty choice in the first event for some + // reason. This should be fixed in the API. + allowEmptyChoices: true, + }) ), chatCompletionDeployments, chatCompletionModels, @@ -327,12 +318,12 @@ describe("OpenAI", function () { ); }); - it("bring your own data", async function () { + it.skip("bring your own data", async function () { if (authMethod === "OpenAIKey") { this.skip(); } updateWithSucceeded( - await withDeployment( + await withDeployments( getSucceeded( authMethod, deployments, @@ -340,8 +331,8 @@ describe("OpenAI", function () { chatCompletionDeployments, chatCompletionModels ), - async (deploymentName) => { - const count = await assertChatCompletionsStream( + async (deploymentName) => + bufferAsyncIterable( client.listChatCompletions(deploymentName, byodMessages, { azureExtensionOptions: { extensions: [ @@ -356,11 +347,8 @@ describe("OpenAI", function () { ], }, }) - ); - if (count === 0) { - logger.warning(`No completions returned for ${deploymentName}`); - } - } + ), + assertChatCompletionsList ), chatCompletionDeployments, chatCompletionModels, diff --git a/sdk/openai/openai/test/public/utils/asserts.ts b/sdk/openai/openai/test/public/utils/asserts.ts index 2b951fbbffef..cc00fb725a66 100644 --- a/sdk/openai/openai/test/public/utils/asserts.ts +++ b/sdk/openai/openai/test/public/utils/asserts.ts @@ -3,6 +3,11 @@ import { assert } from "@azure/test-utils"; import { + AudioResult, + AudioResultFormat, + AudioResultSimpleJson, + AudioResultVerboseJson, + AudioSegment, ChatChoice, ChatCompletions, ChatMessage, @@ -158,13 +163,13 @@ function assertCompletionsNoUsage( function assertChatCompletionsNoUsage( completions: ChatCompletions, - { allowEmptyChoices, ...opts }: ChatCompletionTestOptions + { allowEmptyChoices, allowEmptyId, ...opts }: ChatCompletionTestOptions ): void { if (!allowEmptyChoices || completions.choices.length > 0) { assertNonEmptyArray(completions.choices, (choice) => assertChatChoice(choice, opts)); } assert.instanceOf(completions.created, Date); - assert.isString(completions.id); + ifDefined(completions.id, assert.isString, { defined: !allowEmptyId }); ifDefined(completions.promptFilterResults, assertContentFilterResults); } @@ -197,6 +202,14 @@ export async function assertChatCompletionsStream( ); } +export function assertChatCompletionsList( + list: Array, + options: ChatCompletionTestOptions = {} +): void { + assert.isNotEmpty(list); + list.map((item) => assertChatCompletionsNoUsage(item, { ...options, stream: true })); +} + interface CompletionTestOptions { allowEmptyChoices?: boolean; } @@ -206,4 +219,48 @@ interface ChatCompletionTestOptions { allowEmptyChoices?: boolean; functions?: boolean; allowEmptyStream?: boolean; + allowEmptyId?: boolean; +} + +function assertSegment(segment: AudioSegment): void { + assert.isNumber(segment.start); + assert.isNumber(segment.end); + assert.isString(segment.text); + assert.isNumber(segment.id); + assert.isNumber(segment.avgLogprob); + assert.isNumber(segment.compressionRatio); + assert.isNumber(segment.noSpeechProb); + assert.isNumber(segment.seek); + assert.isNumber(segment.temperature); + assert.isArray(segment.tokens); + segment.tokens.forEach((item) => assert.isNumber(item)); +} + +function assertVerboseJson(result: AudioResultVerboseJson): void { + assert.isString(result.text); + assert.isNumber(result.duration); + assert.isString(result.language); + assert.isString(result.task); + assert.isArray(result.segments); + result.segments.forEach((item) => assertSegment(item)); +} + +export function assertAudioResult( + responseFormat: AudioResultFormat, + result: AudioResult +): void { + switch (responseFormat) { + case "json": + assert.isObject(result); + assert.isString((result as AudioResultSimpleJson).text); + break; + case "verbose_json": + assertVerboseJson(result as AudioResultVerboseJson); + break; + case "srt": + case "vtt": + case "text": + assert.isString(result); + break; + } } diff --git a/sdk/openai/openai/test/public/utils/recordedClient.ts b/sdk/openai/openai/test/public/utils/recordedClient.ts index d183657ce77c..cccf80e1f056 100644 --- a/sdk/openai/openai/test/public/utils/recordedClient.ts +++ b/sdk/openai/openai/test/public/utils/recordedClient.ts @@ -16,6 +16,8 @@ const envSetupForPlayback: { [k: string]: string } = { OPENAI_API_KEY: "openai_api_key", AZURE_API_KEY: "azure_api_key", ENDPOINT: "https://endpoint/", + RESOURCE_GROUP: "resource_group", + ACCOUNT_NAME: "account_name", SUBSCRIPTION_ID: "subscription_id", AZURE_SEARCH_ENDPOINT: "azure_search_endpoint", AZURE_SEARCH_KEY: "azure_search_key", diff --git a/sdk/openai/openai/test/public/utils/utils.ts b/sdk/openai/openai/test/public/utils/utils.ts index 853b7fc20771..37b9cf913820 100644 --- a/sdk/openai/openai/test/public/utils/utils.ts +++ b/sdk/openai/openai/test/public/utils/utils.ts @@ -2,7 +2,6 @@ // Licensed under the MIT license. import { assert } from "@azure/test-utils"; -import { logger } from "./logger.js"; import { PipelineRequest, PipelineResponse, @@ -20,41 +19,50 @@ import { Recorder, assertEnvironmentVariable, isLiveMode, - isPlaybackMode, + isRecordMode, } from "@azure-tools/test-recorder"; import { OpenAIKeyCredential } from "../../../src/index.js"; -export async function withDeployment( +function toString(error: any): string { + return error instanceof Error ? error.toString() + "\n" + error.stack : JSON.stringify(error); +} + +export async function withDeployments( deployments: string[], - run: (model: string) => Promise + run: (model: string) => Promise, + validate: (result: T) => void ): Promise { const errors = []; const succeeded = []; assert.isNotEmpty(deployments, "No deployments found"); for (const deployment of deployments) { try { - logger.verbose(`testing with ${deployment}`); - await run(deployment); + console.log(`testing with ${deployment}`); + const res = await run(deployment); + if (!isRecordMode()) { + validate(res); + } succeeded.push(deployment); } catch (e) { const error = e as any; if (!e) continue; + const errorStr = toString(error); if ( ["OperationNotSupported", "model_not_found", "rate_limit_exceeded"].includes(error.code) || error.type === "invalid_request_error" ) { - logger.verbose(error.toString()); + console.log(`Handled error: ${errorStr}`); continue; } - logger.warning(`Error in deployment ${deployment}: ${error.toString()}`); - errors.push(error instanceof Error ? error.toString() : JSON.stringify(error)); + console.warn(`Error in deployment ${deployment}: ${errorStr}`); + errors.push(errorStr); } } if (errors.length > 0) { throw new Error(`Errors list: ${errors.join("\n")}`); } assert.isNotEmpty(succeeded, "No deployments succeeded"); - logger.info(`Succeeded with (${succeeded.length}): ${succeeded.join(", ")}`); + console.log(`Succeeded with (${succeeded.length}): ${succeeded.join(", ")}`); return succeeded; } @@ -85,7 +93,7 @@ async function listOpenAIModels(cred: KeyCredential, recorder: Recorder): Promis const body = JSON.parse(response.bodyAsText as string); const models = body.data.map((model: { id: string }) => model.id); - logger.verbose(`Available models (${models.length}): ${models.join(", ")}`); + console.log(`Available models (${models.length}): ${models.join(", ")}`); return models; } @@ -107,7 +115,7 @@ async function listDeployments( deployments.push(deploymentName); } } - logger.verbose(`Available deployments (${deployments.length}): ${deployments.join(", ")}`); + console.log(`Available deployments (${deployments.length}): ${deployments.join(", ")}`); return deployments; } @@ -151,8 +159,8 @@ export function getSucceeded( export async function getDeployments(recorder: Recorder): Promise { return listDeployments( assertEnvironmentVariable("SUBSCRIPTION_ID"), - isPlaybackMode() ? "openai-shared" : assertEnvironmentVariable("RESOURCE_GROUP"), - isPlaybackMode() ? "openai-shared" : assertEnvironmentVariable("ACCOUNT_NAME"), + assertEnvironmentVariable("RESOURCE_GROUP"), + assertEnvironmentVariable("ACCOUNT_NAME"), recorder ); } @@ -163,3 +171,11 @@ export async function getModels(recorder: Recorder): Promise { recorder ); } + +export async function bufferAsyncIterable(iter: AsyncIterable): Promise { + const result: T[] = []; + for await (const item of iter) { + result.push(item); + } + return result; +}