diff --git "a/.github/ISSUE_TEMPLATE/api-\347\274\272\345\244\261.md" "b/.github/ISSUE_TEMPLATE/api-\347\274\272\345\244\261.md" new file mode 100644 index 0000000..9cadf76 --- /dev/null +++ "b/.github/ISSUE_TEMPLATE/api-\347\274\272\345\244\261.md" @@ -0,0 +1,16 @@ +--- +name: API 缺失 +about: 百炼文档中提到了某个 API/参数/返回值,但 SDK 中没有 +title: "[API Sync]" +labels: bug, untriaged +assignees: ikesnowy + +--- + +**百炼文档链接** +提供百炼对应的文档链接,以及希望添加的内容 + +**SDK 对应的方法(如果有)** + +**其他信息** +例如,希望 SDK 通过 Enum 提供代码提示友好的接入 diff --git "a/.github/ISSUE_TEMPLATE/bug-\345\217\215\351\246\210.md" "b/.github/ISSUE_TEMPLATE/bug-\345\217\215\351\246\210.md" new file mode 100644 index 0000000..c9d377f --- /dev/null +++ "b/.github/ISSUE_TEMPLATE/bug-\345\217\215\351\246\210.md" @@ -0,0 +1,30 @@ +--- +name: Bug 反馈 +about: 代码不起作用/抛出异常/结果不正确 +title: "[BUG]" +labels: bug, untriaged +assignees: ikesnowy + +--- + +**环境信息** +安装的包及版本(例如 `Cnblogs.DashScope.Sdk/v0.7.6`): +`.NET` 版本(例如 `net8.0)`: +使用的模型名称(例如 `qwen-max`): + +**复现流程** + +进行如下调用时触发了 bug: + +```csharp +// 输入参数 + +// 调用的方法 + +``` + +**预期结果** +按预期,以上调用应当返回 xxx 结果 + +**其他相关信息** +例如初步 debug 截图/部署方法(IIS, docker,k8s,控制台等)/日志信息/使用场景/访问规模等有关 BUG 出现场景的信息。 diff --git "a/.github/ISSUE_TEMPLATE/sdk-\344\275\277\347\224\250\344\275\223\351\252\214.md" "b/.github/ISSUE_TEMPLATE/sdk-\344\275\277\347\224\250\344\275\223\351\252\214.md" new file mode 100644 index 0000000..1fb6156 --- /dev/null +++ "b/.github/ISSUE_TEMPLATE/sdk-\344\275\277\347\224\250\344\275\223\351\252\214.md" @@ -0,0 +1,24 @@ +--- +name: SDK 相关 +about: SDK 本身的改进,例如升级依赖包版本/添加方法重载/提供额外能力等 +title: "[SDK]" +labels: untriaged +assignees: ikesnowy + +--- + +**希望更改的 SDK 内容** + +受影响的 SDK API +```csharp +// 提供 SDK 的方法名或者调用方法 +``` + +希望做出的改进 +```csharp +// 希望以这种方式调用 +``` + +**其他信息** + +使用场景/项目背景/限制条件(例如:无法依赖 xx 包),以及可接受的其他方式 diff --git "a/.github/ISSUE_TEMPLATE/\346\226\207\346\241\243\347\233\270\345\205\263.md" "b/.github/ISSUE_TEMPLATE/\346\226\207\346\241\243\347\233\270\345\205\263.md" new file mode 100644 index 0000000..90e2b60 --- /dev/null +++ "b/.github/ISSUE_TEMPLATE/\346\226\207\346\241\243\347\233\270\345\205\263.md" @@ -0,0 +1,12 @@ +--- +name: 文档相关 +about: SDK 在文档上需要做出的改进 +title: "[DOC]" +labels: documentation, untriaged +assignees: ikesnowy + +--- + +**文档文件位置(如果有)** + +**希望做出的改进** diff --git a/Cnblogs.DashScope.Sdk.sln b/Cnblogs.DashScope.Sdk.sln index 165038d..2cb77fe 100644 --- a/Cnblogs.DashScope.Sdk.sln +++ b/Cnblogs.DashScope.Sdk.sln @@ -16,8 +16,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cnblogs.DashScope.AspNetCor EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cnblogs.DashScope.Core", "src\Cnblogs.DashScope.Core\Cnblogs.DashScope.Core.csproj", "{CC389455-A3EA-4F09-B524-4DC351A1E1AA}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cnblogs.DashScope.Sdk.SnapshotGenerator", "test\Cnblogs.DashScope.Sdk.SnapshotGenerator\Cnblogs.DashScope.Sdk.SnapshotGenerator.csproj", "{5088DE77-1CE3-46FB-B9D0-27A6C9A5EED1}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cnblogs.DashScope.AI", "src\Cnblogs.DashScope.AI\Cnblogs.DashScope.AI.csproj", "{5D5AD75A-8084-4738-AC56-B8A23E649452}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cnblogs.DashScope.AI.UnitTests", "test\Cnblogs.DashScope.AI.UnitTests\Cnblogs.DashScope.AI.UnitTests.csproj", "{25EE79E1-147B-42FD-AFEA-E1550EDD1D36}" @@ -35,7 +33,6 @@ Global {8885149A-78F0-4C8E-B9AA-87A46EA69219} = {2E15D1EC-4A07-416E-8BE6-D907F509FD35} {C910495B-87AB-4AC1-989C-B6720695A139} = {008988ED-0A3B-4272-BCC3-7B4110699345} {CC389455-A3EA-4F09-B524-4DC351A1E1AA} = {008988ED-0A3B-4272-BCC3-7B4110699345} - {5088DE77-1CE3-46FB-B9D0-27A6C9A5EED1} = {CFC8ECB3-5248-46CD-A56C-EC088F2A3804} {5D5AD75A-8084-4738-AC56-B8A23E649452} = {008988ED-0A3B-4272-BCC3-7B4110699345} {25EE79E1-147B-42FD-AFEA-E1550EDD1D36} = {CFC8ECB3-5248-46CD-A56C-EC088F2A3804} {06F0AF23-445B-4C6F-9E19-570DA9B7435D} = {CFC8ECB3-5248-46CD-A56C-EC088F2A3804} @@ -61,10 +58,6 @@ Global {CC389455-A3EA-4F09-B524-4DC351A1E1AA}.Debug|Any CPU.Build.0 = Debug|Any CPU {CC389455-A3EA-4F09-B524-4DC351A1E1AA}.Release|Any CPU.ActiveCfg = Release|Any CPU {CC389455-A3EA-4F09-B524-4DC351A1E1AA}.Release|Any CPU.Build.0 = Release|Any CPU - {5088DE77-1CE3-46FB-B9D0-27A6C9A5EED1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5088DE77-1CE3-46FB-B9D0-27A6C9A5EED1}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5088DE77-1CE3-46FB-B9D0-27A6C9A5EED1}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5088DE77-1CE3-46FB-B9D0-27A6C9A5EED1}.Release|Any CPU.Build.0 = Release|Any CPU {5D5AD75A-8084-4738-AC56-B8A23E649452}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {5D5AD75A-8084-4738-AC56-B8A23E649452}.Debug|Any CPU.Build.0 = Debug|Any CPU {5D5AD75A-8084-4738-AC56-B8A23E649452}.Release|Any CPU.ActiveCfg = Release|Any CPU diff --git a/Cnblogs.DashScope.Sdk.sln.DotSettings b/Cnblogs.DashScope.Sdk.sln.DotSettings index 3ad7f9e..9866f59 100644 --- a/Cnblogs.DashScope.Sdk.sln.DotSettings +++ b/Cnblogs.DashScope.Sdk.sln.DotSettings @@ -1,4 +1,8 @@  + True True True - True \ No newline at end of file + True + True + True + True \ No newline at end of file diff --git a/README.md b/README.md index 8af1a64..18049e2 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,40 @@ English | [简体中文](https://github.com/cnblogs/dashscope-sdk/blob/main/README.zh-Hans.md) +# Cnblogs.DashScopeSDK + [![NuGet Version](https://img.shields.io/nuget/v/Cnblogs.DashScope.AI?style=flat&logo=nuget&label=Cnblogs.DashScope.AI)](https://www.nuget.org/packages/Cnblogs.DashScope.AI) [![NuGet Version](https://img.shields.io/nuget/v/Cnblogs.DashScope.Sdk?style=flat&logo=nuget&label=Cnblogs.DashScope.Sdk&link=https%3A%2F%2Fwww.nuget.org%2Fpackages%2FCnblogs.DashScope.Sdk)](https://www.nuget.org/packages/Cnblogs.DashScope.Sdk) [![NuGet Version](https://img.shields.io/nuget/v/Cnblogs.DashScope.AspNetCore?style=flat&logo=nuget&label=Cnblogs.DashScope.AspNetCore&link=https%3A%2F%2Fwww.nuget.org%2Fpackages%2FCnblogs.DashScope.AspNetCore)](https://www.nuget.org/packages/Cnblogs.DashScope.AspNetCore) -# DashScope SDK for .NET - -An unofficial DashScope SDK maintained by Cnblogs. - -**Warning**: this project is under active development, **Breaking Changes** may introduced without notice or major version change. Make sure you read the Release Notes before upgrading. +A non-official DashScope (Bailian) service SDK maintained by Cnblogs. -# Quick Start +**Note:** This project is actively under development. Breaking changes may occur even in minor versions. Please review the Release Notes before upgrading. -## Using `Microsoft.Extensions.AI` +## Quick Start -Install `Cnblogs.DashScope.AI` Package +### Using `Microsoft.Extensions.AI` Interface +Install NuGet package `Cnblogs.DashScope.AI` ```csharp var client = new DashScopeClient("your-api-key").AsChatClient("qwen-max"); var completion = await client.CompleteAsync("hello"); Console.WriteLine(completion) ``` -## Console App - -Install `Cnblogs.DashScope.Sdk` package. +### Console Application +Install NuGet package `Cnblogs.DashScope.Sdk` ```csharp var client = new DashScopeClient("your-api-key"); var completion = await client.GetQWenCompletionAsync(QWenLlm.QWenMax, prompt); -// or pass the model name string directly. +// Or use model name string // var completion = await client.GetQWenCompletionAsync("qwen-max", prompt); Console.WriteLine(completion.Output.Text); ``` -## ASP.NET Core +### ASP.NET Core Application -Install the Cnblogs.DashScope.AspNetCore package. +Install NuGet package `Cnblogs.DashScope.AspNetCore` `Program.cs` ```csharp @@ -44,86 +42,42 @@ builder.AddDashScopeClient(builder.Configuration); ``` `appsettings.json` + ```json { "DashScope": { - "ApiKey": "your-api-key", + "ApiKey": "your-api-key" } } ``` -`Usage` +Application class: + ```csharp public class YourService(IDashScopeClient client) { public async Task CompletePromptAsync(string prompt) { - var completion = await client.GetQWenCompletionAsync(QWenLlm.QWenMax, prompt); - return completion.Output.Text; + var completion = await client.GetQWenCompletionAsync(QWenLlm.QWenMax, prompt); + return completion.Output.Text; } } ``` +## Supported APIs +- [Chat](#Chat) - QWen3, DeepSeek, etc. Supports reasoning, tool calling, web search, translation +- [Multimodal](#multimodal) - QWen-VL, QVQ, etc. Supports reasoning, visual understanding, OCR, audio understanding +- [Text-to-Speech (TTS)](#Text-to-Speech) - CosyVoice, Sambert +- [Image Generation](#image-generation) - Wanx2.1 (text-to-image, portrait style transfer) +- [Application Call](#application-call) +- [Text Vectorization](#text-vectorization) -# Supported APIs -- Text Embedding API - `GetTextEmbeddingsAsync()` -- Text Generation API(qwen-turbo, qwen-max, etc.) - `GetQWenCompletionAsync()` and `GetQWenCompletionStreamAsync()` -- DeepSeek Models - `GetDeepSeekCompletionAsync()` and `GetDeepSeekCompletionStreamAsync()` -- BaiChuan Models - Use `GetBaiChuanTextCompletionAsync()` -- LLaMa2 Models - `GetLlama2TextCompletionAsync()` -- Multimodal Generation API(qwen-vl-max, etc.) - `GetQWenMultimodalCompletionAsync()` and `GetQWenMultimodalCompletionStreamAsync()` -- Wanx Models(Image generation, background generation, etc) - - Image Synthesis - `CreateWanxImageSynthesisTaskAsync()` and `GetWanxImageSynthesisTaskAsync()` - - Image Generation - `CreateWanxImageGenerationTaskAsync()` and `GetWanxImageGenerationTaskAsync()` - - Background Image Generation - `CreateWanxBackgroundGenerationTaskAsync()` and `GetWanxBackgroundGenerationTaskAsync()` -- File API that used by Qwen-Long - `UploadFileAsync()` and `DeleteFileAsync` -- Application call - `GetApplicationResponseAsync()` and `GetApplicationResponseStreamAsync()` +### Chat -# Examples +Use `GetTextCompletionAsync`/`GetTextCompletionStreamAsync` for direct text generation. +For QWen and DeepSeek, use shortcuts: `GetQWenChatCompletionAsync`/`GetDeepSeekChatCompletionAsync` -Visit [snapshots](./test/Cnblogs.DashScope.Tests.Shared/Utils/Snapshots.cs) for calling samples. - -Visit [tests](./test/Cnblogs.DashScope.Sdk.UnitTests) for more usage of each api. - -## General Text Completion API - -Use `client.GetTextCompletionAsync` and `client.GetTextCompletionStreamAsync` to access text generation api directly. - -```csharp -var completion = await dashScopeClient.GetTextCompletionAsync( - new ModelRequest - { - Model = "your-model-name", - Input = new TextGenerationInput { Prompt = prompt }, - Parameters = new TextGenerationParameters() - { - // control parameters as you wish. - EnableSearch = true - } - }); -var completions = dashScopeClient.GetTextCompletionStreamAsync( - new ModelRequest - { - Model = "your-model-name", - Input = new TextGenerationInput { Messages = [TextChatMessage.System("you are a helpful assistant"), TextChatMessage.User("How are you?")] }, - Parameters = new TextGenerationParameters() - { - // control parameters as you wish. - EnableSearch = true, - IncreamentalOutput = true - } - }); -``` - -## Single Text Completion - -```csharp -var prompt = "hello" -var completion = await client.GetQWenCompletionAsync(QWenLlm.QWenMax, prompt); -Console.WriteLine(completion.Output.Text); -``` - -## Multi-round chat +[Official Documentation](https://help.aliyun.com/zh/model-studio/user-guide/text-generation/) ```csharp var history = new List @@ -140,145 +94,191 @@ var completion = await client.GetQWenChatCompletionAsync(QWenLlm.QWenMax, histor Console.WriteLine(completion.Output.Choices[0].Message.Content); // The number is 42 ``` -## Reasoning - -Use `completion.Output.Choices![0].Message.ReasoningContent` to access the thoughts from reasoning model. +#### Reasoning +Access model thoughts via `ReasoningContent` property ```csharp -var history = new List +var history = new List { - ChatMessage.User("Calculate 1+1") + TextChatMessage.User("Calculate 1+1") }; var completion = await client.GetDeepSeekChatCompletionAsync(DeepSeekLlm.DeepSeekR1, history); Console.WriteLine(completion.Output.Choices[0]!.Message.ReasoningContent); ``` - -### QWen3 - -Use `TextGenerationParameters.EnableThinking` to toggle reasoning. - +For QWen3 models, enable reasoning with `TextGenerationParameters.EnableThinking` ```csharp var stream = dashScopeClient - .GetQWenChatStreamAsync( - QWenLlm.QWenPlusLatest, - history, - new TextGenerationParameters - { - IncrementalOutput = true, - ResultFormat = ResultFormats.Message, - EnableThinking = true - }); + .GetQWenChatStreamAsync( + QWenLlm.QWenPlusLatest, + history, + new TextGenerationParameters + { + IncrementalOutput = true, + ResultFormat = ResultFormats.Message, + EnableThinking = true + }); ``` -## Function Call - -Creates a function with parameters - +#### Tool Calling +Define a function for model to use: ```csharp string GetCurrentWeather(GetCurrentWeatherParameters parameters) { - // actual implementation should be different. - return "Sunny, 14" + parameters.Unit switch - { - TemperatureUnit.Celsius => "℃", - TemperatureUnit.Fahrenheit => "℉" - }; + return "Sunny"; } - public record GetCurrentWeatherParameters( [property: Required] - [property: Description("The city and state, e.g. San Francisco, CA")] + [property: Description("City and state, e.g. San Francisco, CA")] string Location, [property: JsonConverter(typeof(EnumStringConverter))] TemperatureUnit Unit = TemperatureUnit.Celsius); - -public enum TemperatureUnit -{ - Celsius, - Fahrenheit -} +public enum TemperatureUnit { Celsius, Fahrenheit } ``` - -Append tool information to chat messages (Here we use `JsonSchema.NET` to generate JSON Schema). - +Invoke with tool definitions. We using `JsonSchema.Net` for example, you could use any other library to generate JSON schema) ```csharp -var tools = new List() +var tools = new List { new( ToolTypes.Function, new FunctionDefinition( nameof(GetCurrentWeather), - "Get the weather abount given location", + "Get current weather", new JsonSchemaBuilder().FromType().Build())) }; +var history = new List { ChatMessage.User("What's the weather in CA?") }; +var parameters = new TextGenerationParameters { ResultFormat = ResultFormats.Message, Tools = tools }; -var history = new List -{ - ChatMessage.User("What is the weather today in C.A?") -}; - -var parameters = new TextGenerationParamters() -{ - ResultFormat = ResultFormats.Message, - Tools = tools -}; - -// send question with available tools. +// request model var completion = await client.GetQWenChatCompletionAsync(QWenLlm.QWenMax, history, parameters); -history.Add(completion.Output.Choice[0].Message); - -// model responding with tool calls. Console.WriteLine(completion.Output.Choice[0].Message.ToolCalls[0].Function.Name); // GetCurrentWeather +history.Add(completion.Output.Choice[0].Message); -// calling tool that model requests and append result into history. -var result = GetCurrentWeather(JsonSerializer.Deserialize(completion.Output.Choice[0].Message.ToolCalls[0].Function.Arguments)); -history.Add(ChatMessage.Tool(result, nameof(GetCurrentWeather))); +// calls tool +var result = GetCurrentWeather(new() { Location = "CA" }); +history.Add(new("tool", result, nameof(GetCurrentWeather))); -// get back answers. +// Get final answer completion = await client.GetQWenChatCompletionAsync(QWenLlm.QWenMax, history, parameters); -Console.WriteLine(completion.Output.Choice[0].Message.Content); +Console.WriteLine(completion.Output.Choices[0].Message.Content); // "Current weather in California: Sunny" +``` +#### File Upload (Long Context Models) +For Qwen-Long models: +```csharp +var file = new FileInfo("test.txt"); +var uploadedFile = await dashScopeClient.UploadFileAsync(file.OpenRead(), file.Name); +var history = new List { ChatMessage.File(uploadedFile.Id) }; +var completion = await client.GetQWenChatCompletionAsync(QWenLlm.QWenLong, history); +Console.WriteLine(completion.Output.Choices[0].Message.Content); +// Cleanup +await dashScopeClient.DeleteFileAsync(uploadedFile.Id); ``` +### Multimodal +Use `GetMultimodalGenerationAsync`/`GetMultimodalGenerationStreamAsync` +[Official Documentation](https://help.aliyun.com/zh/model-studio/multimodal) -Append the tool calling result with `tool` role, then model will generate answers based on tool calling result. +```csharp +var image = await File.ReadAllBytesAsync("Lenna.jpg"); +var response = dashScopeClient.GetMultimodalGenerationStreamAsync( + new ModelRequest() + { + Model = "qvq-plus", + Input = new MultimodalInput() + { + Messages = + [ + MultimodalMessage.User( + [ + MultimodalMessageContent.ImageContent(image, "image/jpeg"), + MultimodalMessageContent.TextContent("她是谁?") + ]) + ] + }, + Parameters = new MultimodalParameters { IncrementalOutput = true, VlHighResolutionImages = false } + }); + +// output +var reasoning = false; +await foreach (var modelResponse in response) +{ + var choice = modelResponse.Output.Choices.FirstOrDefault(); + if (choice != null) + { + if (choice.FinishReason != "null") + { + break; + } + if (string.IsNullOrEmpty(choice.Message.ReasoningContent) == false) + { + if (reasoning == false) + { + reasoning = true; + Console.WriteLine(""); + } -## QWen-Long with files + Console.Write(choice.Message.ReasoningContent); + continue; + } -Upload file first. + if (reasoning) + { + reasoning = false; + Console.WriteLine(""); + } -```csharp -var file = new FileInfo("test.txt"); -var uploadedFile = await dashScopeClient.UploadFileAsync(file.OpenRead(), file.Name); + Console.Write(choice.Message.Content[0].Text); + } +} ``` +### Text-to-Speech + +Create a speech synthesis session using `dashScopeClient.CreateSpeechSynthesizerSocketSessionAsync()`. -Using uploaded file id in messages. +Note: Use the using statement to automatically dispose the session, or manually call Dispose() to release resources. Avoid reusing sessions. +Create a synthesis session: ```csharp -var history = new List +using var tts = await dashScopeClient.CreateSpeechSynthesizerSocketSessionAsync("cosyvoice-v2"); +var taskId = await tts.RunTaskAsync(new SpeechSynthesizerParameters { Voice = "longxiaochun_v2", Format = "mp3" }); +await tts.ContinueTaskAsync(taskId, "Cnblogs"); +await tts.ContinueTaskAsync(taskId, "Code changes the world"); +await tts.FinishTaskAsync(taskId); +var file = new FileInfo("tts.mp3"); +using var stream = file.OpenWrite(); +await foreach (var b in tts.GetAudioAsync()) { - ChatMessage.File(uploadedFile.Id), // use array for multiple files, e.g. [file1.Id, file2.Id] - ChatMessage.User("Summarize the content of file.") + stream.WriteByte(b); } -var parameters = new TextGenerationParameters() -{ - ResultFormat = ResultFormats.Message -}; -var completion = await client.GetQWenChatCompletionAsync(QWenLlm.QWenLong, history, parameters); -Console.WriteLine(completion.Output.Choices[0].Message.Content); +Console.WriteLine($"Audio saved to {file.FullName}"); ``` - -Delete file if needed - +### Image Generation +#### Text-to-Image +Use shortcuts for Wanx models: ```csharp -var deletionResult = await dashScopeClient.DeleteFileAsync(uploadedFile.Id); +var task = await dashScopeClient.CreateWanxImageSynthesisTaskAsync( + WanxModel.WanxV21Turbo, + "A futuristic cityscape at sunset", + new ImageSynthesisParameters { Style = ImageStyles.OilPainting }); +// Pull status +while (true) +{ + var result = await dashScopeClient.GetWanxImageSynthesisTaskAsync(task.TaskId); + if (result.Output.TaskStatus == DashScopeTaskStatus.Succeeded) + { + Console.WriteLine($"Image URL: {result.Output.Results[0].Url}"); + break; + } + await Task.Delay(500); +} ``` +#### Portrait Style Transfer +Use `CreateWanxImageGenerationTaskAsync` and `GetWanxImageGenerationTaskAsync` -## Application call - -Use `GetApplicationResponseAsync` to call an application. +#### Background Generation -Use `GetApplicationResponseStreamAsync` for streaming output. +Use `CreateWanxBackgroundGenerationTaskAsync` and `GetWanxBackgroundGenerationTaskAsync` +### Application Call ```csharp var request = new ApplicationRequest() @@ -300,8 +300,7 @@ var request = var response = await client.GetApplicationResponseAsync("your-application-id", request); Console.WriteLine(response.Output.Text); ``` - -`ApplicationRequest` use an `Dictionary` as `BizParams` by default. +`ApplicationRequest` uses `Dictionary` as the default type for `BizParams`. ```csharp var request = @@ -319,14 +318,13 @@ var request = var response = await client.GetApplicationResponseAsync("your-application-id", request); Console.WriteLine(response.Output.Text); ``` - -You can use the generic version `ApplicationRequest` for strong-typed `BizParams`. But keep in mind that client use `snake_case` by default when doing json serialization, you may need to use `[JsonPropertyName("camelCase")]` for other type of naming policy. +For strong typing support, you can use the generic class `ApplicationRequest`. +Note that the SDK uses `snake_case` for JSON serialization. If your application uses different naming conventions, manually specify the serialized property names using `[JsonPropertyName("camelCase")]`. ```csharp public record TestApplicationBizParam( [property: JsonPropertyName("sourceCode")] string SourceCode); - var request = new ApplicationRequest() { @@ -340,3 +338,18 @@ var response = await client.GetApplicationResponseAsync("your-application-id", r Console.WriteLine(response.Output.Text); ``` +### Text Vectorization + +```csharp +var text = "Sample text for embedding"; +var response = await dashScopeClient.GetTextEmbeddingsAsync( + TextEmbeddingModel.TextEmbeddingV4, + [text], + new TextEmbeddingParameters { Dimension = 512 }); +var embedding = response.Output.Embeddings.First().Embedding; +Console.WriteLine($"Embedding vector length: {embedding.Length}"); +``` + +See [Snapshot Files](./test/Cnblogs.DashScope.Tests.Shared/Utils/Snapshots.cs) for API parameter examples. + +Review [Tests](./test) for comprehensive usage examples. diff --git a/README.zh-Hans.md b/README.zh-Hans.md index b2545c3..aea5642 100644 --- a/README.zh-Hans.md +++ b/README.zh-Hans.md @@ -1,18 +1,18 @@ [English](https://github.com/cnblogs/dashscope-sdk/blob/main/README.md) | 简体中文 +# Cnblogs.DashScopeSDK + [![NuGet Version](https://img.shields.io/nuget/v/Cnblogs.DashScope.AI?style=flat&logo=nuget&label=Cnblogs.DashScope.AI)](https://www.nuget.org/packages/Cnblogs.DashScope.AI) [![NuGet Version](https://img.shields.io/nuget/v/Cnblogs.DashScope.Sdk?style=flat&logo=nuget&label=Cnblogs.DashScope.Sdk&link=https%3A%2F%2Fwww.nuget.org%2Fpackages%2FCnblogs.DashScope.Sdk)](https://www.nuget.org/packages/Cnblogs.DashScope.Sdk) [![NuGet Version](https://img.shields.io/nuget/v/Cnblogs.DashScope.AspNetCore?style=flat&logo=nuget&label=Cnblogs.DashScope.AspNetCore&link=https%3A%2F%2Fwww.nuget.org%2Fpackages%2FCnblogs.DashScope.AspNetCore)](https://www.nuget.org/packages/Cnblogs.DashScope.AspNetCore) -# Cnblogs.DashScopeSDK - 由博客园维护并使用的非官方灵积(百炼)服务 SDK。 使用前注意:当前项目正在积极开发中,小版本也可能包含破坏性更改,升级前请查看对应版本 Release Note 进行迁移。 -# 快速开始 +## 快速开始 -## 使用 `Microsoft.Extensions.AI` 接口 +### 使用 `Microsoft.Extensions.AI` 接口 安装 NuGet 包 `Cnblogs.DashScope.AI` @@ -22,7 +22,7 @@ var completion = await client.CompleteAsync("hello"); Console.WriteLine(completion) ``` -## 控制台应用 +### 控制台应用 安装 NuGet 包 `Cnblogs.DashScope.Sdk`。 @@ -34,7 +34,7 @@ var completion = await client.GetQWenCompletionAsync(QWenLlm.QWenMax, prompt); Console.WriteLine(completion.Output.Text); ``` -## ASP.NET Core 应用 +### ASP.NET Core 应用 安装 NuGet 包 `Cnblogs.DashScope.AspNetCore`。 @@ -64,70 +64,22 @@ public class YourService(IDashScopeClient client) } ``` -# 支持的 API +## 支持的 API -- 通用文本向量 - `GetTextEmbeddingsAsync()` -- 通义千问(`qwen-turbo`, `qwen-max` 等) - `GetQWenCompletionAsync()` 和 `GetQWenCompletionStreamAsync()` -- DeepSeek 系列模型(`deepseek-r1`,`deepseek-v3` 等) - `GetDeepSeekChatCompletionAsync()` 和 `GetDeepSeekChatCompletionStreamAsync()` -- 百川开源大模型 - `GetBaiChuanTextCompletionAsync()` -- LLaMa2 大语言模型 - `GetLlama2TextCompletionAsync()` -- 通义千问 VL 和通义千问 Audio(`qwen-vl-max`, `qwen-audio`) - `GetQWenMultimodalCompletionAsync()` 和 `GetQWenMultimodalCompletionStreamAsync()` -- 通义万相系列 - - 文生图 - `CreateWanxImageSynthesisTaskAsync()` 和 `GetWanxImageSynthesisTaskAsync()` - - 人像风格重绘 - `CreateWanxImageGenerationTaskAsync()` 和 `GetWanxImageGenerationTaskAsync()` - - 图像背景生成 - `CreateWanxBackgroundGenerationTaskAsync()` 和 `GetWanxBackgroundGenerationTaskAsync()` -- 适用于 QWen-Long 的文件 API `UploadFileAsync()` 和 `DeleteFileAsync` -- 应用调用 `GetApplicationResponseAsync` 和 `GetApplicationResponseStreamAsync()` -- 其他使用相同 Endpoint 的模型 +- [对话](#对话) - QWen3, DeepSeek 等,支持推理/工具调用/网络搜索/翻译等场景 +- [多模态](#多模态) - QWen-VL,QVQ 等,支持推理/视觉理解/OCR/音频理解等场景 +- [语音合成](#语音合成) - CosyVoice,Sambert 等,支持 TTS 等应用场景 +- [图像生成](#图像生成) - wanx2.1 等,支持文生图,人像风格重绘等应用场景 +- [应用调用](#应用调用) +- [文本向量](#文本向量) -# 示例 - -查看 [快照文件](./test/Cnblogs.DashScope.Tests.Shared/Utils/Snapshots.cs) 获得 API 调用参数示例. - -查看 [测试](./test) 获得更多 API 使用示例。 - -## 文本生成 +### 对话 使用 `dashScopeClient.GetTextCompletionAsync` 和 `dashScopeClient.GetTextCompletionStreamAsync` 来直接访问文本生成接口。 -相关文档:https://help.aliyun.com/zh/model-studio/user-guide/text-generation/ - -```csharp -var completion = await dashScopeClient.GetTextCompletionAsync( - new ModelRequest - { - Model = "your-model-name", - Input = new TextGenerationInput { Prompt = prompt }, - Parameters = new TextGenerationParameters() - { - // control parameters as you wish. - EnableSearch = true - } - }); - -var completions = dashScopeClient.GetTextCompletionStreamAsync( - new ModelRequest - { - Model = "your-model-name", - Input = new TextGenerationInput { Messages = [TextChatMessage.System("you are a helpful assistant"), TextChatMessage.User("How are you?")] }, - Parameters = new TextGenerationParameters() - { - // control parameters as you wish. - EnableSearch = true, - IncreamentalOutput = true - } - }); -``` - -## 单轮对话 +针对通义千问和 DeekSeek,我们提供了快捷方法进行调用: `GetQWenChatCompletionAsync` /`GetDeepSeekChatCompletionAsync` -```csharp -var prompt = "你好" -var completion = await client.GetQWenCompletionAsync(QWenLlm.QWenMax, prompt); -Console.WriteLine(completion.Output.Text); -``` - -## 多轮对话 +相关文档:https://help.aliyun.com/zh/model-studio/user-guide/text-generation/ ```csharp var history = new List @@ -144,7 +96,7 @@ var completion = await client.GetQWenChatCompletionAsync(QWenLlm.QWenMax, histor Console.WriteLine(completion.Output.Choices[0].Message.Content); // The number is 42 ``` -## 推理 +#### 推理 使用推理模型时,模型的思考过程可以通过 `ReasoningContent` 属性获取。 @@ -157,9 +109,7 @@ var completion = await client.GetDeepSeekChatCompletionAsync(DeepSeekLlm.DeepSee Console.WriteLine(completion.Output.Choices[0]!.Message.ReasoningContent); ``` -### QWen3 - -使用 `TextGenerationParameters.EnableThinking` 决定是否使用模型的推理能力。 +对于支持的模型(例如 qwen3),可以使用 `TextGenerationParameters.EnableThinking` 决定是否使用模型的推理能力。 ```csharp var stream = dashScopeClient @@ -174,7 +124,7 @@ var stream = dashScopeClient }); ``` -## 工具调用 +#### 工具调用 创建一个可供模型使用的方法。 @@ -241,9 +191,9 @@ Console.WriteLine(completion.Output.Choice[0].Message.Content) // 现在浙江 当模型认为应当调用工具时,返回消息中 `ToolCalls` 会提供调用的详情,本地在调用完成后可以把结果以 `tool` 角色返回。 -## 上传文件(QWen-Long) +#### 上传文件(qwen-long) -需要先提前将文件上传到 DashScope 来获得 Id。 +使用长上下文模型时,需要先提前将文件上传到 DashScope 来获得 Id。 ```csharp var file = new FileInfo("test.txt"); @@ -272,7 +222,162 @@ Console.WriteLine(completion.Output.Choices[0].Message.Content); var deletionResult = await dashScopeClient.DeleteFileAsync(uploadedFile.Id); ``` -## 应用调用 +### 多模态 + +使用 `dashScopeClient.GetMultimodalGenerationAsync` 和 `dashScopeClient.GetMultimodalGenerationStreamAsync` 来访问多模态文本生成接口。 + +相关文档:[多模态_大模型服务平台百炼(Model Studio)-阿里云帮助中心](https://help.aliyun.com/zh/model-studio/multimodal) + +#### 视觉理解/推理 + +使用 `MultimodalMessage.User()` 可以快速创建对应角色的消息。 + +媒体内容可以通过公网 URL 或者 `byte[]` 传入。 + +```csharp +var image = await File.ReadAllBytesAsync("Lenna.jpg"); +var response = dashScopeClient.GetMultimodalGenerationStreamAsync( + new ModelRequest() + { + Model = "qvq-plus", + Input = new MultimodalInput() + { + Messages = + [ + MultimodalMessage.User( + [ + MultimodalMessageContent.ImageContent(image, "image/jpeg"), + MultimodalMessageContent.TextContent("她是谁?") + ]) + ] + }, + Parameters = new MultimodalParameters { IncrementalOutput = true, VlHighResolutionImages = false } + }); + +// output +var reasoning = false; +await foreach (var modelResponse in response) +{ + var choice = modelResponse.Output.Choices.FirstOrDefault(); + if (choice != null) + { + if (choice.FinishReason != "null") + { + break; + } + + if (string.IsNullOrEmpty(choice.Message.ReasoningContent) == false) + { + if (reasoning == false) + { + reasoning = true; + Console.WriteLine(""); + } + + Console.Write(choice.Message.ReasoningContent); + continue; + } + + if (reasoning) + { + reasoning = false; + Console.WriteLine(""); + } + + Console.Write(choice.Message.Content[0].Text); + } +} +``` + +### 语音合成 + +通过 `dashScopeClient.CreateSpeechSynthesizerSocketSessionAsync()` 来创建一个语音合成会话。 + +**注意:使用 using 语句来自动释放会话,或者手动 Dispose 会话,尽量不要重用会话。** + +相关文档:[语音合成-CosyVoice_大模型服务平台百炼(Model Studio)-阿里云帮助中心](https://help.aliyun.com/zh/model-studio/cosyvoice-large-model-for-speech-synthesis) + +```csharp +using var tts = await dashScopeClient.CreateSpeechSynthesizerSocketSessionAsync("cosyvoice-v2"); +var taskId = await tts.RunTaskAsync( + new SpeechSynthesizerParameters { Voice = "longxiaochun_v2", Format = "mp3" }); +await tts.ContinueTaskAsync(taskId, "博客园"); +await tts.ContinueTaskAsync(taskId, "代码改变世界"); +await tts.FinishTaskAsync(taskId); +var file = new FileInfo("tts.mp3"); +using var stream = file.OpenWrite(); +await foreach (var b in tts.GetAudioAsync()) +{ + stream.WriteByte(b); +} + +stream.Close(); + +var tokenUsage = 0; +await foreach (var message in tts.GetMessagesAsync()) +{ + if (message.Payload.Usage?.Characters > tokenUsage) + { + tokenUsage = message.Payload.Usage.Characters; + } +} + +Console.WriteLine($"audio saved to {file.FullName}, token usage: {tokenUsage}"); +break; +``` + +### 图像生成 + +#### 文生图 + +我们针对通义万相提供了快捷 API `dashScopeClient.CreateWanxImageSynthesisTaskAsync()` 和 `GetWanxImageSynthesisTaskAsync()`。 + +图片生成需要数秒到数十秒不等,对于 HTTP 请求来说太长,需要通过任务方式生成。 + +先使用 `CreateWanxImageSynthesisTaskAsync()` 创建任务,再轮询 `GetWanxImageSynthesisTaskAsync()` 检查任务完成状态。 + +相关文档:[通义万相2.1文生图V2版API参考_大模型服务平台百炼(Model Studio)-阿里云帮助中心](https://help.aliyun.com/zh/model-studio/text-to-image-v2-api-reference) + +```csharp +var prompt = Console.ReadLine(); +var task = await dashScopeClient.CreateWanxImageSynthesisTaskAsync( + WanxModel.WanxV21Turbo, + prompt, + null, + new ImageSynthesisParameters { Style = ImageStyles.OilPainting }); +Console.WriteLine($"Task({task.TaskId}) submitted, checking status..."); +var watch = Stopwatch.StartNew(); +while (watch.Elapsed.TotalSeconds < 120) +{ + var result = await dashScopeClient.GetWanxImageSynthesisTaskAsync(task.TaskId); + Console.WriteLine($"{watch.ElapsedMilliseconds}ms - Status: {result.Output.TaskStatus}"); + if (result.Output.TaskStatus == DashScopeTaskStatus.Succeeded) + { + Console.WriteLine($"Image generation finished, URL: {result.Output.Results![0].Url}"); + return; + } + + if (result.Output.TaskStatus == DashScopeTaskStatus.Failed) + { + Console.WriteLine($"Image generation failed, error message: {result.Output.Message}"); + return; + } + + await Task.Delay(500); +} + +Console.WriteLine($"Task timout, taskId: {task.TaskId}"); +``` + +#### 人像风格重绘和图像背景生成 + +与文生图类似,先创建任务,再轮询状态。 + +人像风格重绘 - `CreateWanxImageGenerationTaskAsync` 和 `GetWanxImageGenerationTaskAsync` + +图像背景生成 - `CreateWanxBackgroundGenerationTaskAsync` 和 `GetWanxBackgroundGenerationTaskAsync` + +### 应用调用 `GetApplicationResponseAsync` 用于进行应用调用。 @@ -339,3 +444,25 @@ var request = var response = await client.GetApplicationResponseAsync("your-application-id", request); Console.WriteLine(response.Output.Text); ``` + +### 文本向量 + +使用 `GetTextEmbeddingsAsync` 来调用文本向量接口。 + +相关文档:[通用文本向量同步接口API详情_大模型服务平台百炼(Model Studio)-阿里云帮助中心](https://help.aliyun.com/zh/model-studio/text-embedding-synchronous-api) + +```csharp +var text = Console.ReadLine(); +var response = await dashScopeClient.GetTextEmbeddingsAsync( + TextEmbeddingModel.TextEmbeddingV4, + [text], + new TextEmbeddingParameters() { Dimension = 512, }); +var array = response.Output.Embeddings.First().Embedding; +Console.WriteLine("Embedding"); +Console.WriteLine(string.Join('\n', array)); +Console.WriteLine($"Token usage: {response.Usage?.TotalTokens}"); +``` + +查看 [快照文件](./test/Cnblogs.DashScope.Tests.Shared/Utils/Snapshots.cs) 获得 API 调用参数示例. + +查看 [测试](./test) 获得更多 API 使用示例。 diff --git a/sample/Cnblogs.DashScope.Sample/Cnblogs.DashScope.Sample.csproj b/sample/Cnblogs.DashScope.Sample/Cnblogs.DashScope.Sample.csproj index ba80da7..fd1bb50 100644 --- a/sample/Cnblogs.DashScope.Sample/Cnblogs.DashScope.Sample.csproj +++ b/sample/Cnblogs.DashScope.Sample/Cnblogs.DashScope.Sample.csproj @@ -1,26 +1,29 @@  - - Exe - net8.0 - enable - enable - false - + + Exe + net8.0 + enable + enable + false + - - - - + + + + - - - Always - - + + + Always + + + PreserveNewest + + - - - + + + diff --git a/sample/Cnblogs.DashScope.Sample/Lenna.jpg b/sample/Cnblogs.DashScope.Sample/Lenna.jpg new file mode 100644 index 0000000..4030eed Binary files /dev/null and b/sample/Cnblogs.DashScope.Sample/Lenna.jpg differ diff --git a/sample/Cnblogs.DashScope.Sample/Program.cs b/sample/Cnblogs.DashScope.Sample/Program.cs index 87bfe69..a0aa669 100644 --- a/sample/Cnblogs.DashScope.Sample/Program.cs +++ b/sample/Cnblogs.DashScope.Sample/Program.cs @@ -1,15 +1,19 @@ -using System.Text; +using System.Diagnostics; +using System.Text; using System.Text.Json; using Cnblogs.DashScope.Core; using Cnblogs.DashScope.Sample; using Cnblogs.DashScope.Sdk; using Cnblogs.DashScope.Sdk.QWen; +using Cnblogs.DashScope.Sdk.TextEmbedding; +using Cnblogs.DashScope.Sdk.Wanx; using Json.Schema; using Json.Schema.Generation; using Microsoft.Extensions.AI; Console.WriteLine("Reading key from environment variable DASHSCOPE_KEY"); -var apiKey = Environment.GetEnvironmentVariable("DASHSCOPE_API_KEY"); +var apiKey = Environment.GetEnvironmentVariable("DASHSCOPE_KEY", EnvironmentVariableTarget.Process) + ?? Environment.GetEnvironmentVariable("DASHSCOPE_KEY", EnvironmentVariableTarget.User); if (string.IsNullOrEmpty(apiKey)) { Console.Write("ApiKey > "); @@ -47,9 +51,15 @@ case SampleType.ChatCompletionWithTool: await ChatWithToolsAsync(); break; + case SampleType.MultimodalCompletion: + await ChatWithImageAsync(); + break; case SampleType.ChatCompletionWithFiles: await ChatWithFilesAsync(); break; + case SampleType.Text2Image: + await Text2ImageAsync(); + break; case SampleType.MicrosoftExtensionsAi: await ChatWithMicrosoftExtensions(); break; @@ -63,6 +73,54 @@ userInput = Console.ReadLine()!; await ApplicationCallAsync(applicationId, userInput); break; + case SampleType.TextToSpeech: + { + using var tts = await dashScopeClient.CreateSpeechSynthesizerSocketSessionAsync("cosyvoice-v2"); + var taskId = await tts.RunTaskAsync( + new SpeechSynthesizerParameters { Voice = "longxiaochun_v2", Format = "mp3" }); + await tts.ContinueTaskAsync(taskId, "博客园"); + await tts.ContinueTaskAsync(taskId, "代码改变世界"); + await tts.FinishTaskAsync(taskId); + var file = new FileInfo("tts.mp3"); + await using var stream = file.OpenWrite(); + await foreach (var b in tts.GetAudioAsync()) + { + stream.WriteByte(b); + } + + stream.Close(); + + var tokenUsage = 0; + await foreach (var message in tts.GetMessagesAsync()) + { + if (message.Payload.Usage?.Characters > tokenUsage) + { + tokenUsage = message.Payload.Usage.Characters; + } + } + + Console.WriteLine($"audio saved to {file.FullName}, token usage: {tokenUsage}"); + break; + } + + case SampleType.TextEmbedding: + Console.Write("text> "); + var text = Console.ReadLine(); + if (string.IsNullOrEmpty(text)) + { + text = "Coding changes world"; + Console.WriteLine($"using default text: {text}"); + } + + var response = await dashScopeClient.GetTextEmbeddingsAsync( + TextEmbeddingModel.TextEmbeddingV3, + [text], + new TextEmbeddingParameters() { Dimension = 512, }); + var array = response.Output.Embeddings.First().Embedding; + Console.WriteLine("Embedding"); + Console.WriteLine(string.Join('\n', array)); + Console.WriteLine($"Token usage: {response.Usage?.TotalTokens}"); + break; } return; @@ -130,6 +188,60 @@ async Task ChatStreamAsync() // ReSharper disable once FunctionNeverReturns } +async Task ChatWithImageAsync() +{ + var image = await File.ReadAllBytesAsync("Lenna.jpg"); + var response = dashScopeClient.GetMultimodalGenerationStreamAsync( + new ModelRequest() + { + Model = "qvq-plus", + Input = new MultimodalInput() + { + Messages = + [ + MultimodalMessage.User( + [ + MultimodalMessageContent.ImageContent(image, "image/jpeg"), + MultimodalMessageContent.TextContent("她是谁?") + ]) + ] + }, + Parameters = new MultimodalParameters { IncrementalOutput = true, VlHighResolutionImages = false } + }); + var reasoning = false; + await foreach (var modelResponse in response) + { + var choice = modelResponse.Output.Choices.FirstOrDefault(); + if (choice != null) + { + if (choice.FinishReason != "null") + { + break; + } + + if (string.IsNullOrEmpty(choice.Message.ReasoningContent) == false) + { + if (reasoning == false) + { + reasoning = true; + Console.WriteLine(""); + } + + Console.Write(choice.Message.ReasoningContent); + continue; + } + + if (reasoning) + { + reasoning = false; + Console.WriteLine(""); + } + + Console.Write(choice.Message.Content[0].Text); + } + } +} + async Task ChatWithFilesAsync() { var history = new List(); @@ -228,6 +340,45 @@ async Task ChatWithMicrosoftExtensions() Console.WriteLine(JsonSerializer.Serialize(response, serializerOptions)); } +async Task Text2ImageAsync() +{ + Console.Write("Prompt> "); + var prompt = Console.ReadLine(); + if (string.IsNullOrEmpty(prompt)) + { + Console.WriteLine("Using sample prompt"); + prompt = "A fluffy cat"; + } + + var task = await dashScopeClient.CreateWanxImageSynthesisTaskAsync( + WanxModel.WanxV21Turbo, + prompt, + null, + new ImageSynthesisParameters { Style = ImageStyles.OilPainting }); + Console.WriteLine($"Task({task.TaskId}) submitted, checking status..."); + var watch = Stopwatch.StartNew(); + while (watch.Elapsed.TotalSeconds < 120) + { + var result = await dashScopeClient.GetWanxImageSynthesisTaskAsync(task.TaskId); + Console.WriteLine($"{watch.ElapsedMilliseconds}ms - Status: {result.Output.TaskStatus}"); + if (result.Output.TaskStatus == DashScopeTaskStatus.Succeeded) + { + Console.WriteLine($"Image generation finished, URL: {result.Output.Results![0].Url}"); + return; + } + + if (result.Output.TaskStatus == DashScopeTaskStatus.Failed) + { + Console.WriteLine($"Image generation failed, error message: {result.Output.Message}"); + return; + } + + await Task.Delay(500); + } + + Console.WriteLine($"Task timout, taskId: {task.TaskId}"); +} + async Task ApplicationCallAsync(string applicationId, string prompt) { var request = new ApplicationRequest { Input = new ApplicationInput { Prompt = prompt } }; diff --git a/sample/Cnblogs.DashScope.Sample/SampleType.cs b/sample/Cnblogs.DashScope.Sample/SampleType.cs index feddf79..c78d94a 100644 --- a/sample/Cnblogs.DashScope.Sample/SampleType.cs +++ b/sample/Cnblogs.DashScope.Sample/SampleType.cs @@ -12,9 +12,17 @@ public enum SampleType ChatCompletionWithFiles, + MultimodalCompletion, + + Text2Image, + MicrosoftExtensionsAi, MicrosoftExtensionsAiToolCall, - ApplicationCall + ApplicationCall, + + TextToSpeech, + + TextEmbedding } diff --git a/sample/Cnblogs.DashScope.Sample/SampleTypeDescriptor.cs b/sample/Cnblogs.DashScope.Sample/SampleTypeDescriptor.cs index 26988a5..2cd398a 100644 --- a/sample/Cnblogs.DashScope.Sample/SampleTypeDescriptor.cs +++ b/sample/Cnblogs.DashScope.Sample/SampleTypeDescriptor.cs @@ -11,9 +11,13 @@ public static string GetDescription(this SampleType sampleType) SampleType.ChatCompletion => "Conversation between user and assistant", SampleType.ChatCompletionWithTool => "Function call sample", SampleType.ChatCompletionWithFiles => "File upload sample using qwen-long", + SampleType.MultimodalCompletion => "Multimodal completion", + SampleType.Text2Image => "Text to Image generation", SampleType.MicrosoftExtensionsAi => "Use with Microsoft.Extensions.AI", SampleType.MicrosoftExtensionsAiToolCall => "Use tool call with Microsoft.Extensions.AI interfaces", SampleType.ApplicationCall => "Call pre-defined application", + SampleType.TextToSpeech => "TTS task", + SampleType.TextEmbedding => "Get text embedding", _ => throw new ArgumentOutOfRangeException(nameof(sampleType), sampleType, "Unsupported sample option") }; } diff --git a/src/Cnblogs.DashScope.AI/Cnblogs.DashScope.AI.csproj b/src/Cnblogs.DashScope.AI/Cnblogs.DashScope.AI.csproj index 8de1ebd..c34f277 100644 --- a/src/Cnblogs.DashScope.AI/Cnblogs.DashScope.AI.csproj +++ b/src/Cnblogs.DashScope.AI/Cnblogs.DashScope.AI.csproj @@ -11,7 +11,7 @@ - + diff --git a/src/Cnblogs.DashScope.AspNetCore/Assembly.cs b/src/Cnblogs.DashScope.AspNetCore/Assembly.cs new file mode 100644 index 0000000..b79c4ed --- /dev/null +++ b/src/Cnblogs.DashScope.AspNetCore/Assembly.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +[assembly:InternalsVisibleTo("Cnblogs.DashScope.Sdk.UnitTests")] +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] diff --git a/src/Cnblogs.DashScope.AspNetCore/Cnblogs.DashScope.AspNetCore.csproj b/src/Cnblogs.DashScope.AspNetCore/Cnblogs.DashScope.AspNetCore.csproj index 1ab06a5..ba30d5f 100644 --- a/src/Cnblogs.DashScope.AspNetCore/Cnblogs.DashScope.AspNetCore.csproj +++ b/src/Cnblogs.DashScope.AspNetCore/Cnblogs.DashScope.AspNetCore.csproj @@ -3,7 +3,8 @@ Cnblogs.DashScopeSDK true - Cnblogs;Dashscope;AI;Sdk;Embedding;AspNetCore + Cnblogs;Dashscope;AI;Sdk;Embedding;AspNetCore;Bailian + Cnblogs.DashScope.AspNetCore diff --git a/src/Cnblogs.DashScope.AspNetCore/DashScopeAspNetCoreDefaults.cs b/src/Cnblogs.DashScope.AspNetCore/DashScopeAspNetCoreDefaults.cs new file mode 100644 index 0000000..1cdbb63 --- /dev/null +++ b/src/Cnblogs.DashScope.AspNetCore/DashScopeAspNetCoreDefaults.cs @@ -0,0 +1,6 @@ +namespace Cnblogs.DashScope.AspNetCore; + +internal static class DashScopeAspNetCoreDefaults +{ + public const string DefaultHttpClientName = "Cnblogs.DashScope.Http"; +} diff --git a/src/Cnblogs.DashScope.AspNetCore/DashScopeClientAspNetCore.cs b/src/Cnblogs.DashScope.AspNetCore/DashScopeClientAspNetCore.cs new file mode 100644 index 0000000..be4a685 --- /dev/null +++ b/src/Cnblogs.DashScope.AspNetCore/DashScopeClientAspNetCore.cs @@ -0,0 +1,20 @@ +using Cnblogs.DashScope.Core; + +namespace Cnblogs.DashScope.AspNetCore; + +/// +/// The with DI and options pattern support. +/// +public class DashScopeClientAspNetCore + : DashScopeClientCore +{ + /// + /// The with DI and options pattern support. + /// + /// The factory to create . + /// The socket pool for WebSocket API calls. + public DashScopeClientAspNetCore(IHttpClientFactory factory, DashScopeClientWebSocketPool pool) + : base(factory.CreateClient(DashScopeAspNetCoreDefaults.DefaultHttpClientName), pool) + { + } +} diff --git a/src/Cnblogs.DashScope.AspNetCore/ServiceCollectionInjector.cs b/src/Cnblogs.DashScope.AspNetCore/ServiceCollectionInjector.cs index ee6b00a..8b521f0 100644 --- a/src/Cnblogs.DashScope.AspNetCore/ServiceCollectionInjector.cs +++ b/src/Cnblogs.DashScope.AspNetCore/ServiceCollectionInjector.cs @@ -1,6 +1,9 @@ using System.Net.Http.Headers; +using Cnblogs.DashScope.AspNetCore; using Cnblogs.DashScope.Core; +using Cnblogs.DashScope.Core.Internals; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; // ReSharper disable once CheckNamespace namespace Microsoft.Extensions.DependencyInjection; @@ -37,9 +40,10 @@ public static IHttpClientBuilder AddDashScopeClient(this IServiceCollection serv { var apiKey = section["apiKey"] ?? throw new InvalidOperationException("There is no apiKey provided in given section"); - var baseAddress = section["baseAddress"]; + var baseAddress = section["baseAddress"] ?? DashScopeDefaults.HttpApiBaseAddress; var workspaceId = section["workspaceId"]; - return services.AddDashScopeClient(apiKey, baseAddress, workspaceId); + services.Configure(section); + return services.AddDashScopeHttpClient(apiKey, baseAddress, workspaceId); } /// @@ -48,16 +52,49 @@ public static IHttpClientBuilder AddDashScopeClient(this IServiceCollection serv /// The service collection to add service to. /// The DashScope api key. /// The DashScope api base address, you may change this value if you are using proxy. + /// The DashScope websocket base address, you may want to change this value if use are using proxy. /// Default workspace id to use. /// public static IHttpClientBuilder AddDashScopeClient( this IServiceCollection services, string apiKey, string? baseAddress = null, + string? baseWebsocketAddress = null, string? workspaceId = null) { - baseAddress ??= "https://dashscope.aliyuncs.com/api/v1/"; - return services.AddHttpClient( + services.Configure(o => + { + o.ApiKey = apiKey; + if (baseAddress != null) + { + o.BaseAddress = baseAddress; + } + + if (baseWebsocketAddress != null) + { + o.WebsocketBaseAddress = baseWebsocketAddress; + } + + o.WorkspaceId = workspaceId; + }); + + return services.AddDashScopeHttpClient(apiKey, baseAddress, workspaceId); + } + + private static IHttpClientBuilder AddDashScopeHttpClient( + this IServiceCollection services, + string apiKey, + string? baseAddress, + string? workspaceId) + { + services.AddSingleton(); + services.AddSingleton(sp + => new DashScopeClientWebSocketPool( + sp.GetRequiredService(), + sp.GetRequiredService>().Value)); + services.AddScoped(); + return services.AddHttpClient( + DashScopeAspNetCoreDefaults.DefaultHttpClientName, h => { h.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); @@ -66,7 +103,7 @@ public static IHttpClientBuilder AddDashScopeClient( h.DefaultRequestHeaders.Add("X-DashScope-WorkSpace", workspaceId); } - h.BaseAddress = new Uri(baseAddress); + h.BaseAddress = new Uri(baseAddress ?? DashScopeDefaults.HttpApiBaseAddress); }); } } diff --git a/src/Cnblogs.DashScope.Core/Cnblogs.DashScope.Core.csproj b/src/Cnblogs.DashScope.Core/Cnblogs.DashScope.Core.csproj index b164ed0..f337ae6 100644 --- a/src/Cnblogs.DashScope.Core/Cnblogs.DashScope.Core.csproj +++ b/src/Cnblogs.DashScope.Core/Cnblogs.DashScope.Core.csproj @@ -3,7 +3,7 @@ Cnblogs.DashScopeSDK true - Cnblogs;Dashscope;AI;Sdk;Embedding; + Cnblogs;Dashscope;AI;Sdk;Embedding;Bailian; Provide pure api access to DashScope without extra references. Cnblogs.DashScope.Sdk should be used for general purpose. @@ -15,5 +15,5 @@ - + diff --git a/src/Cnblogs.DashScope.Core/DashScopeClient.cs b/src/Cnblogs.DashScope.Core/DashScopeClient.cs index dea20f9..1d1b0ca 100644 --- a/src/Cnblogs.DashScope.Core/DashScopeClient.cs +++ b/src/Cnblogs.DashScope.Core/DashScopeClient.cs @@ -9,14 +9,17 @@ namespace Cnblogs.DashScope.Core; public class DashScopeClient : DashScopeClientCore { private static readonly Dictionary ClientPools = new(); + private static readonly Dictionary SocketPools = new(); /// /// Creates a DashScopeClient for further api call. /// /// The DashScope api key. /// The timeout for internal http client, defaults to 2 minute. - /// The base address for dashscope api call. + /// The base address for DashScope api call. + /// The base address for DashScope websocket api call. /// The workspace id. + /// Maximum size of socket pool. /// /// The underlying httpclient is cached by constructor parameter list. /// Client created with same parameter value will share same underlying instance. @@ -24,10 +27,42 @@ public class DashScopeClient : DashScopeClientCore public DashScopeClient( string apiKey, TimeSpan? timeout = null, - string? baseAddress = null, + string baseAddress = DashScopeDefaults.HttpApiBaseAddress, + string baseWebsocketAddress = DashScopeDefaults.WebsocketApiBaseAddress, + string? workspaceId = null, + int socketPoolSize = 32) + : base( + GetConfiguredClient(apiKey, timeout, baseAddress, workspaceId), + GetConfiguredSocketPool(apiKey, baseWebsocketAddress, socketPoolSize, workspaceId)) + { + } + + private static DashScopeClientWebSocketPool GetConfiguredSocketPool( + string apiKey, + string baseAddress, + int socketPoolSize, string? workspaceId = null) - : base(GetConfiguredClient(apiKey, timeout, baseAddress, workspaceId)) { + var key = GetCacheKey(); + + var pool = SocketPools.GetValueOrDefault(key); + if (pool is null) + { + pool = new DashScopeClientWebSocketPool( + new DashScopeClientWebSocketFactory(), + new DashScopeOptions + { + ApiKey = apiKey, + WebsocketBaseAddress = baseAddress, + SocketPoolSize = socketPoolSize, + WorkspaceId = workspaceId + }); + SocketPools.Add(key, pool); + } + + return pool; + + string GetCacheKey() => $"{apiKey}-{socketPoolSize}-{baseAddress}-{workspaceId}"; } private static HttpClient GetConfiguredClient( @@ -41,7 +76,7 @@ private static HttpClient GetConfiguredClient( { client = new HttpClient { - BaseAddress = new Uri(baseAddress ?? DashScopeDefaults.DashScopeApiBaseAddress), + BaseAddress = new Uri(baseAddress ?? DashScopeDefaults.HttpApiBaseAddress), Timeout = timeout ?? TimeSpan.FromMinutes(2) }; diff --git a/src/Cnblogs.DashScope.Core/DashScopeClientCore.cs b/src/Cnblogs.DashScope.Core/DashScopeClientCore.cs index e0f3159..997ef63 100644 --- a/src/Cnblogs.DashScope.Core/DashScopeClientCore.cs +++ b/src/Cnblogs.DashScope.Core/DashScopeClientCore.cs @@ -4,7 +4,6 @@ using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; -using System.Text.Json.Serialization; using Cnblogs.DashScope.Core.Internals; namespace Cnblogs.DashScope.Core; @@ -14,22 +13,18 @@ namespace Cnblogs.DashScope.Core; /// public class DashScopeClientCore : IDashScopeClient { - private static readonly JsonSerializerOptions SerializationOptions = - new() - { - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower - }; - private readonly HttpClient _httpClient; + private readonly DashScopeClientWebSocketPool _socketPool; /// /// For DI container to inject pre-configured httpclient. /// /// Pre-configured httpclient. - public DashScopeClientCore(HttpClient httpClient) + /// Websocket pool. + public DashScopeClientCore(HttpClient httpClient, DashScopeClientWebSocketPool pool) { _httpClient = httpClient; + _socketPool = pool; } /// @@ -283,6 +278,15 @@ public async Task DeleteFileAsync( return (await SendCompatibleAsync(request, cancellationToken))!; } + /// + public async Task CreateSpeechSynthesizerSocketSessionAsync( + string modelId, + CancellationToken cancellationToken = default) + { + var socket = await _socketPool.RentSocketAsync(cancellationToken); + return new SpeechSynthesizerSocketSession(socket, modelId); + } + private static HttpRequestMessage BuildSseRequest(HttpMethod method, string url, TPayload payload) where TPayload : class { @@ -304,7 +308,9 @@ private static HttpRequestMessage BuildRequest( { var message = new HttpRequestMessage(method, url) { - Content = payload != null ? JsonContent.Create(payload, options: SerializationOptions) : null + Content = payload != null + ? JsonContent.Create(payload, options: DashScopeDefaults.SerializationOptions) + : null }; if (sse) @@ -340,7 +346,9 @@ private static HttpRequestMessage BuildRequest( }, HttpCompletionOption.ResponseContentRead, cancellationToken); - return await response.Content.ReadFromJsonAsync(SerializationOptions, cancellationToken); + return await response.Content.ReadFromJsonAsync( + DashScopeDefaults.SerializationOptions, + cancellationToken); } private async Task SendAsync(HttpRequestMessage message, CancellationToken cancellationToken) @@ -350,7 +358,9 @@ private static HttpRequestMessage BuildRequest( message, HttpCompletionOption.ResponseContentRead, cancellationToken); - return await response.Content.ReadFromJsonAsync(SerializationOptions, cancellationToken); + return await response.Content.ReadFromJsonAsync( + DashScopeDefaults.SerializationOptions, + cancellationToken); } private async IAsyncEnumerable StreamAsync( @@ -373,7 +383,8 @@ private async IAsyncEnumerable StreamAsync( var data = line["data:".Length..]; if (data.StartsWith("{\"code\":")) { - var error = JsonSerializer.Deserialize(data, SerializationOptions)!; + var error = + JsonSerializer.Deserialize(data, DashScopeDefaults.SerializationOptions)!; throw new DashScopeException( message.RequestUri?.ToString(), (int)response.StatusCode, @@ -381,7 +392,7 @@ private async IAsyncEnumerable StreamAsync( error.Message); } - yield return JsonSerializer.Deserialize(data, SerializationOptions)!; + yield return JsonSerializer.Deserialize(data, DashScopeDefaults.SerializationOptions)!; } } } @@ -418,7 +429,9 @@ private async Task GetSuccessResponseAsync( DashScopeError? error = null; try { - var r = await response.Content.ReadFromJsonAsync(SerializationOptions, cancellationToken); + var r = await response.Content.ReadFromJsonAsync( + DashScopeDefaults.SerializationOptions, + cancellationToken); error = r == null ? null : errorMapper.Invoke(r); } catch (Exception) diff --git a/src/Cnblogs.DashScope.Core/DashScopeClientWebSocket.cs b/src/Cnblogs.DashScope.Core/DashScopeClientWebSocket.cs new file mode 100644 index 0000000..464aa4c --- /dev/null +++ b/src/Cnblogs.DashScope.Core/DashScopeClientWebSocket.cs @@ -0,0 +1,262 @@ +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; +using System.Threading.Channels; +using Cnblogs.DashScope.Core.Internals; + +namespace Cnblogs.DashScope.Core; + +/// +/// A websocket client for DashScope websocket API. +/// +public sealed class DashScopeClientWebSocket : IDisposable +{ + private static readonly UnboundedChannelOptions UnboundedChannelOptions = + new() + { + SingleWriter = true, + SingleReader = true, + AllowSynchronousContinuations = true + }; + + private readonly IClientWebSocket _socket; + private Task? _receiveTask; + private TaskCompletionSource _taskStartedSignal = new(); + private Channel? _binaryOutput; + private Channel>? _jsonOutput; + + /// + /// Unique id of this socket. + /// + internal Guid Id { get; } = Guid.NewGuid(); + + /// + /// The binary output. + /// + public ChannelReader BinaryOutput + => _binaryOutput?.Reader + ?? throw new InvalidOperationException("Please call ResetOutput() before accessing output"); + + /// + /// The json output. + /// + /// Throws when ResetOutput is not called. + public ChannelReader> JsonOutput + => _jsonOutput?.Reader + ?? throw new InvalidOperationException("Please call ResetOutput() before accessing output"); + + /// + /// A task that completed when received task-started event. + /// + public Task TaskStarted => _taskStartedSignal.Task; + + /// + /// Current state for this websocket. + /// + public DashScopeWebSocketState State { get; private set; } + + /// + /// Initialize a configured web socket client. + /// + /// The api key to use. + /// Optional workspace id. + public DashScopeClientWebSocket(string apiKey, string? workspaceId = null) + { + _socket = new ClientWebSocketWrapper(new ClientWebSocket()); + _socket.Options.SetRequestHeader("X-DashScope-DataInspection", "enable"); + _socket.Options.SetRequestHeader("Authorization", $"bearer {apiKey}"); + if (string.IsNullOrEmpty(workspaceId) == false) + { + _socket.Options.SetRequestHeader("X-DashScope-WorkspaceId", workspaceId); + } + } + + /// + /// Initiate a with a pre-configured . + /// + /// Pre-configured . + internal DashScopeClientWebSocket(IClientWebSocket socket) + { + _socket = socket; + } + + /// + /// Start a websocket connection. + /// + /// Websocket API uri. + /// The cancellation token to use. + /// + /// When was request. + public async Task ConnectAsync(Uri uri, CancellationToken cancellationToken = default) + { + await _socket.ConnectAsync(uri, cancellationToken); + _receiveTask = ReceiveMessagesAsync(cancellationToken); + State = DashScopeWebSocketState.Ready; + } + + /// + /// Reset binary output. + /// + public void ResetOutput() + { + _binaryOutput?.Writer.TryComplete(); + _binaryOutput = Channel.CreateUnbounded(UnboundedChannelOptions); + _jsonOutput?.Writer.TryComplete(); + _jsonOutput = Channel.CreateUnbounded>(UnboundedChannelOptions); + _taskStartedSignal.TrySetResult(false); + _taskStartedSignal = new TaskCompletionSource(); + } + + /// + /// Send message to server. + /// + /// Request to send. + /// A cancellation token used to propagate notification that this operation should be canceled. + /// + /// Type of the input. + /// Type of the parameter. + /// The is requested. + /// Websocket is not connected or already closed. + /// The underlying websocket has already been closed. + public Task SendMessageAsync( + DashScopeWebSocketRequest request, + CancellationToken cancellationToken = default) + where TInput : class, new() + where TParameter : class + { + if (State == DashScopeWebSocketState.Closed) + { + throw new InvalidOperationException("Socket is already closed."); + } + + var json = JsonSerializer.Serialize(request, DashScopeDefaults.SerializationOptions); + return _socket.SendAsync( + new ArraySegment(Encoding.UTF8.GetBytes(json)), + WebSocketMessageType.Text, + true, + cancellationToken); + } + + private async Task?> ReceiveMessageAsync( + CancellationToken cancellationToken = default) + { + var buffer = new byte[1024 * 4]; + var segment = new ArraySegment(buffer); + + try + { + var result = await _socket.ReceiveAsync(segment, cancellationToken); + if (result.MessageType == WebSocketMessageType.Close) + { + await CloseAsync(cancellationToken); + return null; + } + + if (result.MessageType == WebSocketMessageType.Binary) + { + for (var i = 0; i < result.Count; i++) + { + await _binaryOutput!.Writer.WriteAsync(buffer[i], cancellationToken); + } + + return null; + } + + var message = Encoding.UTF8.GetString(buffer, 0, result.Count); + var jsonResponse = + JsonSerializer.Deserialize>( + message, + DashScopeDefaults.SerializationOptions); + return jsonResponse; + } + catch + { + // close socket when exception happens. + await CloseAsync(cancellationToken); + } + + return null; + } + + /// + /// Wait for server response. + /// + /// A cancellation token used to propagate notification that this operation should be canceled. + /// The task was failed. + private async Task ReceiveMessagesAsync(CancellationToken cancellationToken = default) + { + while (State != DashScopeWebSocketState.Closed && _socket.CloseStatus == null) + { + var json = await ReceiveMessageAsync(cancellationToken); + if (json == null) + { + continue; + } + + if (_jsonOutput is not null) + { + await _jsonOutput.Writer.WriteAsync(json, cancellationToken); + } + + var eventStr = json.Header.Event; + switch (eventStr) + { + case "task-started": + State = DashScopeWebSocketState.RunningTask; + _taskStartedSignal.TrySetResult(true); + break; + case "task-finished": + State = DashScopeWebSocketState.Ready; + _binaryOutput?.Writer.Complete(); + _jsonOutput?.Writer.Complete(); + break; + case "task-failed": + await CloseAsync(cancellationToken); + _binaryOutput?.Writer.Complete(); + _jsonOutput?.Writer.Complete(); + throw new DashScopeException( + null, + 400, + new DashScopeError + { + Code = json.Header.ErrorCode ?? string.Empty, + Message = json.Header.ErrorMessage ?? string.Empty, + RequestId = json.Header.Attributes.RequestUuid ?? string.Empty + }, + json.Header.ErrorMessage ?? "The task was failed"); + } + } + + await CloseAsync(cancellationToken); + } + + /// + /// Close the underlying websocket. + /// + /// A cancellation token used to propagate notification that this operation should be canceled. + /// + public async Task CloseAsync(CancellationToken cancellationToken = default) + { + await _socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closing", cancellationToken); + State = DashScopeWebSocketState.Closed; + _binaryOutput?.Writer.TryComplete(); + _jsonOutput?.Writer.TryComplete(); + } + + private void Dispose(bool disposing) + { + if (disposing) + { + // Dispose managed resources. + _socket.Dispose(); + _binaryOutput?.Writer.TryComplete(); + _jsonOutput?.Writer.TryComplete(); + } + } + + /// + public void Dispose() + { + Dispose(true); + } +} diff --git a/src/Cnblogs.DashScope.Core/DashScopeClientWebSocketFactory.cs b/src/Cnblogs.DashScope.Core/DashScopeClientWebSocketFactory.cs new file mode 100644 index 0000000..06edecb --- /dev/null +++ b/src/Cnblogs.DashScope.Core/DashScopeClientWebSocketFactory.cs @@ -0,0 +1,13 @@ +namespace Cnblogs.DashScope.Core; + +/// +/// Default implementation for . +/// +public class DashScopeClientWebSocketFactory : IDashScopeClientWebSocketFactory +{ + /// + public DashScopeClientWebSocket GetClientWebSocket(string apiKey, string? workspaceId = null) + { + return new DashScopeClientWebSocket(apiKey, workspaceId); + } +} diff --git a/src/Cnblogs.DashScope.Core/DashScopeClientWebSocketPool.cs b/src/Cnblogs.DashScope.Core/DashScopeClientWebSocketPool.cs new file mode 100644 index 0000000..57cbed5 --- /dev/null +++ b/src/Cnblogs.DashScope.Core/DashScopeClientWebSocketPool.cs @@ -0,0 +1,140 @@ +using System.Collections.Concurrent; + +namespace Cnblogs.DashScope.Core; + +/// +/// Socket pool for DashScope API. +/// +public sealed class DashScopeClientWebSocketPool : IDisposable +{ + private readonly ConcurrentBag _available = new(); + private readonly ConcurrentDictionary _active = new(); + private readonly DashScopeOptions _options; + private readonly IDashScopeClientWebSocketFactory _dashScopeClientWebSocketFactory; + + /// + /// Socket pool for DashScope API. + /// + /// + /// Options for DashScope sdk. + public DashScopeClientWebSocketPool( + IDashScopeClientWebSocketFactory dashScopeClientWebSocketFactory, + DashScopeOptions options) + { + _dashScopeClientWebSocketFactory = dashScopeClientWebSocketFactory; + _options = options; + } + + /// + /// Get available connection count. + /// + internal int AvailableSocketCount => _available.Count; + + /// + /// Get active connection count. + /// + internal int ActiveSocketCount => _active.Count; + + internal DashScopeClientWebSocketPool( + IEnumerable sockets, + IDashScopeClientWebSocketFactory dashScopeClientWebSocketFactory) + { + _options = new DashScopeOptions(); + foreach (var socket in sockets) + { + _available.Add(socket); + } + + _dashScopeClientWebSocketFactory = dashScopeClientWebSocketFactory; + } + + internal void ReturnSocket(DashScopeClientWebSocket socket) + { + _active.Remove(socket.Id, out _); + + if (socket.State != DashScopeWebSocketState.Ready) + { + // not returnable, disposing. + socket.Dispose(); + return; + } + + _available.Add(socket); + } + + /// + /// Rent or create a socket connection from pool. + /// + /// + /// + public async Task RentSocketAsync(CancellationToken cancellationToken = default) + { + var found = false; + DashScopeClientWebSocket? socket = null; + while (found == false) + { + if (_available.IsEmpty == false) + { + found = _available.TryTake(out socket); + if (socket?.State != DashScopeWebSocketState.Ready) + { + // expired + found = false; + socket?.Dispose(); + } + } + else + { + socket = await InitializeNewSocketAsync(_options.WebsocketBaseAddress, cancellationToken); + found = true; + } + } + + return ActivateSocket(socket!); + } + + private DashScopeClientWebSocketWrapper ActivateSocket(DashScopeClientWebSocket socket) + { + _active.TryAdd(socket.Id, socket); + return new DashScopeClientWebSocketWrapper(socket, this); + } + + private async Task InitializeNewSocketAsync( + string url, + CancellationToken cancellationToken = default) + { + if (_available.Count + _active.Count >= _options.SocketPoolSize) + { + throw new InvalidOperationException("[DashScopeSDK] Socket pool is full"); + } + + var socket = _dashScopeClientWebSocketFactory.GetClientWebSocket(_options.ApiKey, _options.WorkspaceId); + await socket.ConnectAsync(new Uri(url), cancellationToken); + return socket; + } + + private void Dispose(bool disposing) + { + if (disposing) + { + // Dispose managed resources. + while (_available.IsEmpty == false) + { + _available.TryTake(out var socket); + socket?.Dispose(); + } + + var activeSockets = _active.Values; + foreach (var activeSocket in activeSockets) + { + activeSocket.Dispose(); + } + } + } + + /// + public void Dispose() + { + Dispose(true); + } +} diff --git a/src/Cnblogs.DashScope.Core/DashScopeClientWebSocketWrapper.cs b/src/Cnblogs.DashScope.Core/DashScopeClientWebSocketWrapper.cs new file mode 100644 index 0000000..696f304 --- /dev/null +++ b/src/Cnblogs.DashScope.Core/DashScopeClientWebSocketWrapper.cs @@ -0,0 +1,56 @@ +using System.Text.Json; + +namespace Cnblogs.DashScope.Core; + +/// +/// Represents a transient wrapper for rented websocket, should be transient. +/// +/// The rented websocket +/// The pool to return the socket to. +public sealed record DashScopeClientWebSocketWrapper(DashScopeClientWebSocket Socket, DashScopeClientWebSocketPool Pool) + : IDisposable +{ + /// + /// The binary output. + /// + public IAsyncEnumerable BinaryOutput => Socket.BinaryOutput.ReadAllAsync(); + + /// + /// The json message output. + /// + public IAsyncEnumerable> JsonOutput => Socket.JsonOutput.ReadAllAsync(); + + /// + /// Reset task signal and output cannel. + /// + public void ResetTask() => Socket.ResetOutput(); + + /// + /// The task that completes when received task-started event from server. + /// + public Task TaskStarted => Socket.TaskStarted; + + /// + /// Send message to server. + /// + /// Request to send. + /// A cancellation token used to propagate notification that this operation should be canceled. + /// + /// Type of the input. + /// Type of the parameter. + /// The is requested. + /// Websocket is not connected. + /// The underlying websocket has already been closed. + public Task SendMessageAsync( + DashScopeWebSocketRequest request, + CancellationToken cancellationToken = default) + where TInput : class, new() + where TParameter : class + => Socket.SendMessageAsync(request, cancellationToken); + + /// + public void Dispose() + { + Pool.ReturnSocket(Socket); + } +} diff --git a/src/Cnblogs.DashScope.Core/DashScopeOptions.cs b/src/Cnblogs.DashScope.Core/DashScopeOptions.cs new file mode 100644 index 0000000..d68d1bb --- /dev/null +++ b/src/Cnblogs.DashScope.Core/DashScopeOptions.cs @@ -0,0 +1,34 @@ +using Cnblogs.DashScope.Core.Internals; + +namespace Cnblogs.DashScope.Core; + +/// +/// Options for DashScope client. +/// +public class DashScopeOptions +{ + /// + /// The api key used to access DashScope api + /// + public string ApiKey { get; set; } = string.Empty; + + /// + /// Base address for DashScope HTTP API. + /// + public string BaseAddress { get; set; } = DashScopeDefaults.HttpApiBaseAddress; + + /// + /// Base address for DashScope websocket API. + /// + public string WebsocketBaseAddress { get; set; } = DashScopeDefaults.WebsocketApiBaseAddress; + + /// + /// Default workspace Id. + /// + public string? WorkspaceId { get; set; } + + /// + /// Default socket pool size. + /// + public int SocketPoolSize { get; set; } = 32; +} diff --git a/src/Cnblogs.DashScope.Core/DashScopeWebSocketRequest.cs b/src/Cnblogs.DashScope.Core/DashScopeWebSocketRequest.cs new file mode 100644 index 0000000..b1845e0 --- /dev/null +++ b/src/Cnblogs.DashScope.Core/DashScopeWebSocketRequest.cs @@ -0,0 +1,21 @@ +namespace Cnblogs.DashScope.Core; + +/// +/// Represents a websocket request to DashScope. +/// +/// Type of the input. +/// Type of the parameter. +public class DashScopeWebSocketRequest + where TInput : class, new() + where TParameter : class +{ + /// + /// Metadata of the request. + /// + public DashScopeWebSocketRequestHeader Header { get; set; } = new(); + + /// + /// Payload of the request. + /// + public DashScopeWebSocketRequestPayload Payload { get; set; } = new(); +} diff --git a/src/Cnblogs.DashScope.Core/DashScopeWebSocketRequestHeader.cs b/src/Cnblogs.DashScope.Core/DashScopeWebSocketRequestHeader.cs new file mode 100644 index 0000000..a54c9c0 --- /dev/null +++ b/src/Cnblogs.DashScope.Core/DashScopeWebSocketRequestHeader.cs @@ -0,0 +1,22 @@ +namespace Cnblogs.DashScope.Core; + +/// +/// Metadata for websocket request. +/// +public class DashScopeWebSocketRequestHeader +{ + /// + /// Action name. + /// + public string Action { get; set; } = string.Empty; + + /// + /// UUID for task. + /// + public string TaskId { get; set; } = string.Empty; + + /// + /// Streaming type. + /// + public string? Streaming { get; set; } = "duplex"; +} diff --git a/src/Cnblogs.DashScope.Core/DashScopeWebSocketRequestPayload.cs b/src/Cnblogs.DashScope.Core/DashScopeWebSocketRequestPayload.cs new file mode 100644 index 0000000..e994239 --- /dev/null +++ b/src/Cnblogs.DashScope.Core/DashScopeWebSocketRequestPayload.cs @@ -0,0 +1,41 @@ +namespace Cnblogs.DashScope.Core; + +/// +/// Payload for websocket request. +/// +/// Type of the input. +/// Type of the parameter. +public class DashScopeWebSocketRequestPayload + where TInput : class, new() // Input's default value must be empty object(not null or omitted). + where TParameter : class +{ + /// + /// Group name of task. + /// + public string? TaskGroup { get; set; } + + /// + /// Requesting task name. + /// + public string? Task { get; set; } + + /// + /// Requesting function name. + /// + public string? Function { get; set; } + + /// + /// Model id. + /// + public string? Model { get; set; } + + /// + /// Optional parameters. + /// + public TParameter? Parameters { get; set; } + + /// + /// The input of the request. + /// + public TInput Input { get; set; } = new(); +} diff --git a/src/Cnblogs.DashScope.Core/DashScopeWebSocketResponse.cs b/src/Cnblogs.DashScope.Core/DashScopeWebSocketResponse.cs new file mode 100644 index 0000000..bffc68b --- /dev/null +++ b/src/Cnblogs.DashScope.Core/DashScopeWebSocketResponse.cs @@ -0,0 +1,11 @@ +namespace Cnblogs.DashScope.Core; + +/// +/// Response from websocket API. +/// +/// Response metadatas. +/// Response body. +/// Output type of the response. +public record DashScopeWebSocketResponse( + DashScopeWebSocketResponseHeader Header, + DashScopeWebSocketResponsePayload Payload); diff --git a/src/Cnblogs.DashScope.Core/DashScopeWebSocketResponseHeader.cs b/src/Cnblogs.DashScope.Core/DashScopeWebSocketResponseHeader.cs new file mode 100644 index 0000000..26bbb9f --- /dev/null +++ b/src/Cnblogs.DashScope.Core/DashScopeWebSocketResponseHeader.cs @@ -0,0 +1,16 @@ +namespace Cnblogs.DashScope.Core; + +/// +/// Metadata of the websocket response. +/// +/// TaskId of the task. +/// Event name. +/// Error code when is task-failed. +/// Error message when is task-failed. +/// Optional attributes +public record DashScopeWebSocketResponseHeader( + string TaskId, + string Event, + string? ErrorCode, + string? ErrorMessage, + DashScopeWebSocketResponseHeaderAttributes Attributes); diff --git a/src/Cnblogs.DashScope.Core/DashScopeWebSocketResponseHeaderAttributes.cs b/src/Cnblogs.DashScope.Core/DashScopeWebSocketResponseHeaderAttributes.cs new file mode 100644 index 0000000..1ee663e --- /dev/null +++ b/src/Cnblogs.DashScope.Core/DashScopeWebSocketResponseHeaderAttributes.cs @@ -0,0 +1,7 @@ +namespace Cnblogs.DashScope.Core; + +/// +/// Attributes field in websocket response header. +/// +/// UUID for current request. +public record DashScopeWebSocketResponseHeaderAttributes(string? RequestUuid); diff --git a/src/Cnblogs.DashScope.Core/DashScopeWebSocketResponseMapper.cs b/src/Cnblogs.DashScope.Core/DashScopeWebSocketResponseMapper.cs new file mode 100644 index 0000000..3dea7d0 --- /dev/null +++ b/src/Cnblogs.DashScope.Core/DashScopeWebSocketResponseMapper.cs @@ -0,0 +1,27 @@ +using System.Text.Json; +using Cnblogs.DashScope.Core.Internals; + +namespace Cnblogs.DashScope.Core; + +/// +/// Mapper class for +/// +internal static class DashScopeWebSocketResponseMapper +{ + public static DashScopeWebSocketResponse DeserializeOutput(this DashScopeWebSocketResponse source) + where TOutput : class + { + var output = source.Payload.Output; + if (output.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + return new DashScopeWebSocketResponse( + source.Header, + new DashScopeWebSocketResponsePayload(null, source.Payload.Usage)); + } + + var mapped = output.Deserialize(DashScopeDefaults.SerializationOptions); + return new DashScopeWebSocketResponse( + source.Header, + new DashScopeWebSocketResponsePayload(mapped, source.Payload.Usage)); + } +} diff --git a/src/Cnblogs.DashScope.Core/DashScopeWebSocketResponsePayload.cs b/src/Cnblogs.DashScope.Core/DashScopeWebSocketResponsePayload.cs new file mode 100644 index 0000000..cce48aa --- /dev/null +++ b/src/Cnblogs.DashScope.Core/DashScopeWebSocketResponsePayload.cs @@ -0,0 +1,9 @@ +namespace Cnblogs.DashScope.Core; + +/// +/// Payload field of websocket API response. +/// +/// Content of the response. +/// Task usage info. +/// Type of the response content. +public record DashScopeWebSocketResponsePayload(TOutput? Output, DashScopeWebSocketResponseUsage? Usage); diff --git a/src/Cnblogs.DashScope.Core/DashScopeWebSocketResponseUsage.cs b/src/Cnblogs.DashScope.Core/DashScopeWebSocketResponseUsage.cs new file mode 100644 index 0000000..7e7f734 --- /dev/null +++ b/src/Cnblogs.DashScope.Core/DashScopeWebSocketResponseUsage.cs @@ -0,0 +1,7 @@ +namespace Cnblogs.DashScope.Core; + +/// +/// Usage info of websocket task. +/// +/// Current character usage count. +public record DashScopeWebSocketResponseUsage(int Characters); diff --git a/src/Cnblogs.DashScope.Core/DashScopeWebSocketState.cs b/src/Cnblogs.DashScope.Core/DashScopeWebSocketState.cs new file mode 100644 index 0000000..2cb73b1 --- /dev/null +++ b/src/Cnblogs.DashScope.Core/DashScopeWebSocketState.cs @@ -0,0 +1,27 @@ +namespace Cnblogs.DashScope.Core; + +/// +/// The state of . +/// +public enum DashScopeWebSocketState +{ + /// + /// The socket has been created but not connected yet. + /// + Created, + + /// + /// The socket has been connected and ready. + /// + Ready, + + /// + /// The socket has a running task waiting to be finished. + /// + RunningTask, + + /// + /// The socket has been closed. + /// + Closed +} diff --git a/src/Cnblogs.DashScope.Core/IDashScopeClient.cs b/src/Cnblogs.DashScope.Core/IDashScopeClient.cs index a123050..cb61eb5 100644 --- a/src/Cnblogs.DashScope.Core/IDashScopeClient.cs +++ b/src/Cnblogs.DashScope.Core/IDashScopeClient.cs @@ -247,4 +247,14 @@ public Task UploadFileAsync( public Task DeleteFileAsync( DashScopeFileId id, CancellationToken cancellationToken = default); + + /// + /// Start a speech synthesizer session. Related model: cosyvoice + /// + /// The model to use. + /// Cancellation token. + /// + public Task CreateSpeechSynthesizerSocketSessionAsync( + string modelId, + CancellationToken cancellationToken = default); } diff --git a/src/Cnblogs.DashScope.Core/IDashScopeClientWebSocketFactory.cs b/src/Cnblogs.DashScope.Core/IDashScopeClientWebSocketFactory.cs new file mode 100644 index 0000000..0cf7359 --- /dev/null +++ b/src/Cnblogs.DashScope.Core/IDashScopeClientWebSocketFactory.cs @@ -0,0 +1,15 @@ +namespace Cnblogs.DashScope.Core; + +/// +/// A factory abstraction for a component that can create DashScopeClientWebSocket instance. +/// +public interface IDashScopeClientWebSocketFactory +{ + /// + /// Creates a new . + /// + /// The api key. + /// Optional workspace id. + /// + DashScopeClientWebSocket GetClientWebSocket(string apiKey, string? workspaceId = null); +} diff --git a/src/Cnblogs.DashScope.Core/IImageSynthesisParameters.cs b/src/Cnblogs.DashScope.Core/IImageSynthesisParameters.cs index 5e241c6..70a3b47 100644 --- a/src/Cnblogs.DashScope.Core/IImageSynthesisParameters.cs +++ b/src/Cnblogs.DashScope.Core/IImageSynthesisParameters.cs @@ -24,4 +24,14 @@ public interface IImageSynthesisParameters /// Seed for randomizer, max at 4294967290. Once set, generated image will use seed, seed+1, seed+2, seed+3 depends on . /// public uint? Seed { get; } + + /// + /// Let LLM to rewrite your positive prompt, Defaults to true. + /// + public bool? PromptExtend { get; } + + /// + /// Adds AI-Generated watermark on bottom right corner. + /// + public bool? Watermark { get; } } diff --git a/src/Cnblogs.DashScope.Core/ITextGenerationParameters.cs b/src/Cnblogs.DashScope.Core/ITextGenerationParameters.cs index 37a3849..9c3b763 100644 --- a/src/Cnblogs.DashScope.Core/ITextGenerationParameters.cs +++ b/src/Cnblogs.DashScope.Core/ITextGenerationParameters.cs @@ -4,7 +4,8 @@ namespace Cnblogs.DashScope.Core; /// The text generation options. /// public interface ITextGenerationParameters - : IIncrementalOutputParameter, ISeedParameter, IProbabilityParameter, IPenaltyParameter, IMaxTokenParameter, IStopTokenParameter + : IIncrementalOutputParameter, ISeedParameter, IProbabilityParameter, IPenaltyParameter, IMaxTokenParameter, + IStopTokenParameter { /// /// The format of the result, must be text or message. @@ -40,11 +41,31 @@ public interface ITextGenerationParameters /// public bool? EnableSearch { get; } + /// + /// Search options. should set to true. + /// + public TextGenerationSearchOptions? SearchOptions { get; set; } + /// /// Thinking option. Valid for supported models.(e.g. qwen3) /// public bool? EnableThinking { get; } + /// + /// Maximum length of thinking content. Valid for supported models.(e.g. qwen3) + /// + public int? ThinkingBudget { get; set; } + + /// + /// Include log possibilities in response. + /// + public bool? Logprobs { get; set; } + + /// + /// How many choices should be returned. Range: [0, 5] + /// + public int? TopLogprobs { get; set; } + /// /// Available tools for model to call. /// @@ -59,4 +80,9 @@ public interface ITextGenerationParameters /// Whether to enable parallel tool calling /// public bool? ParallelToolCalls { get; } + + /// + /// Options when using QWen-MT models. + /// + public TextGenerationTranslationOptions? TranslationOptions { get; set; } } diff --git a/src/Cnblogs.DashScope.Core/ImageSynthesisParameters.cs b/src/Cnblogs.DashScope.Core/ImageSynthesisParameters.cs index 58497fe..8ca30bd 100644 --- a/src/Cnblogs.DashScope.Core/ImageSynthesisParameters.cs +++ b/src/Cnblogs.DashScope.Core/ImageSynthesisParameters.cs @@ -16,4 +16,10 @@ public class ImageSynthesisParameters : IImageSynthesisParameters /// public uint? Seed { get; set; } + + /// + public bool? PromptExtend { get; set; } + + /// + public bool? Watermark { get; set; } } diff --git a/src/Cnblogs.DashScope.Core/Internals/Assembly.cs b/src/Cnblogs.DashScope.Core/Internals/Assembly.cs new file mode 100644 index 0000000..e628d36 --- /dev/null +++ b/src/Cnblogs.DashScope.Core/Internals/Assembly.cs @@ -0,0 +1,5 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Cnblogs.DashScope.Sdk.UnitTests")] +[assembly: InternalsVisibleTo("Cnblogs.DashScope.Tests.Shared")] +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] diff --git a/src/Cnblogs.DashScope.Core/Internals/ByteArrayLiteralConvertor.cs b/src/Cnblogs.DashScope.Core/Internals/ByteArrayLiteralConvertor.cs new file mode 100644 index 0000000..509e4c1 --- /dev/null +++ b/src/Cnblogs.DashScope.Core/Internals/ByteArrayLiteralConvertor.cs @@ -0,0 +1,43 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Cnblogs.DashScope.Core.Internals; + +internal class ByteArrayLiteralConvertor : JsonConverter +{ + /// + public override byte[]? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.StartArray) + { + reader.Read(); // read out start of array + var list = new List(8); // should fit most tokens + while (reader.TokenType != JsonTokenType.EndArray) + { + list.Add(reader.GetByte()); + reader.Read(); + } + + return list.ToArray(); + } + + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + return reader.GetBytesFromBase64(); + } + + /// + public override void Write(Utf8JsonWriter writer, byte[] value, JsonSerializerOptions options) + { + writer.WriteStartArray(); + foreach (var b in value) + { + writer.WriteNumberValue(b); + } + + writer.WriteEndArray(); + } +} diff --git a/src/Cnblogs.DashScope.Core/Internals/ClientWebSocketWrapper.cs b/src/Cnblogs.DashScope.Core/Internals/ClientWebSocketWrapper.cs new file mode 100644 index 0000000..71ebddb --- /dev/null +++ b/src/Cnblogs.DashScope.Core/Internals/ClientWebSocketWrapper.cs @@ -0,0 +1,47 @@ +using System.Net.WebSockets; + +namespace Cnblogs.DashScope.Core.Internals; + +internal sealed class ClientWebSocketWrapper : IClientWebSocket +{ + private readonly ClientWebSocket _socket; + + public ClientWebSocketWrapper(ClientWebSocket socket) + { + _socket = socket; + } + + /// + public void Dispose() + { + _socket.Dispose(); + } + + /// + public ClientWebSocketOptions Options => _socket.Options; + + /// + public WebSocketCloseStatus? CloseStatus => _socket.CloseStatus; + + /// + public Task ConnectAsync(Uri uri, CancellationToken cancellation) => _socket.ConnectAsync(uri, cancellation); + + /// + public Task SendAsync( + ArraySegment buffer, + WebSocketMessageType messageType, + bool endOfMessage, + CancellationToken cancellationToken) + => _socket.SendAsync(buffer, messageType, endOfMessage, cancellationToken); + + /// + public Task ReceiveAsync(ArraySegment buffer, CancellationToken cancellationToken) + => _socket.ReceiveAsync(buffer, cancellationToken); + + /// + public Task CloseAsync( + WebSocketCloseStatus closeStatus, + string? statusDescription, + CancellationToken cancellationToken) + => _socket.CloseAsync(closeStatus, statusDescription, cancellationToken); +} diff --git a/src/Cnblogs.DashScope.Core/Internals/DashScopeDefaults.cs b/src/Cnblogs.DashScope.Core/Internals/DashScopeDefaults.cs index 5c1b537..7623c1b 100644 --- a/src/Cnblogs.DashScope.Core/Internals/DashScopeDefaults.cs +++ b/src/Cnblogs.DashScope.Core/Internals/DashScopeDefaults.cs @@ -1,6 +1,30 @@ -namespace Cnblogs.DashScope.Core.Internals; +using System.Text.Json; +using System.Text.Json.Serialization; -internal static class DashScopeDefaults +namespace Cnblogs.DashScope.Core.Internals; + +/// +/// Default values for DashScope client. +/// +public static class DashScopeDefaults { - public const string DashScopeApiBaseAddress = "https://dashscope.aliyuncs.com/api/v1/"; + /// + /// Base address for HTTP API. + /// + public const string HttpApiBaseAddress = "https://dashscope.aliyuncs.com/api/v1/"; + + /// + /// Base address for websocket API. + /// + public const string WebsocketApiBaseAddress = "wss://dashscope.aliyuncs.com/api-ws/v1/inference/"; + + /// + /// Default json serializer options. + /// + public static readonly JsonSerializerOptions SerializationOptions = + new() + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + }; } diff --git a/src/Cnblogs.DashScope.Core/Internals/IClientWebSocket.cs b/src/Cnblogs.DashScope.Core/Internals/IClientWebSocket.cs new file mode 100644 index 0000000..b4a3cd7 --- /dev/null +++ b/src/Cnblogs.DashScope.Core/Internals/IClientWebSocket.cs @@ -0,0 +1,25 @@ +using System.Net.WebSockets; + +namespace Cnblogs.DashScope.Core.Internals; + +/// +/// Extract for testing purpose. +/// +internal interface IClientWebSocket : IDisposable +{ + public ClientWebSocketOptions Options { get; } + + public WebSocketCloseStatus? CloseStatus { get; } + + public Task ConnectAsync(Uri uri, CancellationToken cancellation); + + public Task SendAsync( + ArraySegment buffer, + WebSocketMessageType messageType, + bool endOfMessage, + CancellationToken cancellationToken); + + Task ReceiveAsync(ArraySegment buffer, CancellationToken cancellationToken); + + Task CloseAsync(WebSocketCloseStatus closeStatus, string? statusDescription, CancellationToken cancellationToken); +} diff --git a/src/Cnblogs.DashScope.Core/MultimodalMessage.cs b/src/Cnblogs.DashScope.Core/MultimodalMessage.cs index d1034b0..48bcc44 100644 --- a/src/Cnblogs.DashScope.Core/MultimodalMessage.cs +++ b/src/Cnblogs.DashScope.Core/MultimodalMessage.cs @@ -7,7 +7,11 @@ namespace Cnblogs.DashScope.Core; /// /// The role associated with this message. /// The contents of this message. -public record MultimodalMessage(string Role, IReadOnlyList Content) +/// Thoughts from the model. +public record MultimodalMessage( + string Role, + IReadOnlyList Content, + string? ReasoningContent = null) : IMessage> { /// @@ -34,9 +38,12 @@ public static MultimodalMessage System(IReadOnlyList c /// Creates an assistant message. /// /// Message contents. + /// Thoughts from the model. /// - public static MultimodalMessage Assistant(IReadOnlyList contents) + public static MultimodalMessage Assistant( + IReadOnlyList contents, + string? reasoningContent = null) { - return new MultimodalMessage(DashScopeRoleNames.Assistant, contents); + return new MultimodalMessage(DashScopeRoleNames.Assistant, contents, reasoningContent); } } diff --git a/src/Cnblogs.DashScope.Core/SpeechSynthesizerInput.cs b/src/Cnblogs.DashScope.Core/SpeechSynthesizerInput.cs new file mode 100644 index 0000000..8421fe2 --- /dev/null +++ b/src/Cnblogs.DashScope.Core/SpeechSynthesizerInput.cs @@ -0,0 +1,12 @@ +namespace Cnblogs.DashScope.Core; + +/// +/// Input for TTS task. +/// +public class SpeechSynthesizerInput +{ + /// + /// Input text, can be null for run-task command. + /// + public string? Text { get; set; } +} diff --git a/src/Cnblogs.DashScope.Core/SpeechSynthesizerOutput.cs b/src/Cnblogs.DashScope.Core/SpeechSynthesizerOutput.cs new file mode 100644 index 0000000..11a000d --- /dev/null +++ b/src/Cnblogs.DashScope.Core/SpeechSynthesizerOutput.cs @@ -0,0 +1,7 @@ +namespace Cnblogs.DashScope.Core; + +/// +/// Output for TTS task. +/// +/// The output sentences. +public record SpeechSynthesizerOutput(SpeechSynthesizerOutputSentences? Sentence); diff --git a/src/Cnblogs.DashScope.Core/SpeechSynthesizerOutputSentences.cs b/src/Cnblogs.DashScope.Core/SpeechSynthesizerOutputSentences.cs new file mode 100644 index 0000000..0698aba --- /dev/null +++ b/src/Cnblogs.DashScope.Core/SpeechSynthesizerOutputSentences.cs @@ -0,0 +1,7 @@ +namespace Cnblogs.DashScope.Core; + +/// +/// Sentences for TTS output. +/// +/// Output words. +public record SpeechSynthesizerOutputSentences(string[]? Words); diff --git a/src/Cnblogs.DashScope.Core/SpeechSynthesizerParameters.cs b/src/Cnblogs.DashScope.Core/SpeechSynthesizerParameters.cs new file mode 100644 index 0000000..8e59c19 --- /dev/null +++ b/src/Cnblogs.DashScope.Core/SpeechSynthesizerParameters.cs @@ -0,0 +1,47 @@ +namespace Cnblogs.DashScope.Core; + +/// +/// Parameters for TTS task. +/// +public class SpeechSynthesizerParameters +{ + /// + /// Fixed to "PlainText" + /// + public string TextType { get; set; } = "PlainText"; + + /// + /// The voice to use. + /// + public string Voice { get; set; } = string.Empty; + + /// + /// Output file format, can be pcm, wav or mp3. + /// + public string? Format { get; set; } + + /// + /// Output audio sample rate. + /// + public int? SampleRate { get; set; } + + /// + /// Output audio volume, range between 0-100, defaults to 50. + /// + public int? Volume { get; set; } + + /// + /// Speech speed, range between 0.5~2.0, defaults to 1.0. + /// + public float? Rate { get; set; } + + /// + /// Pitch of the voice, range between 0.5~2, defaults to 1.0. + /// + public float? Pitch { get; set; } + + /// + /// Enable SSML, you can only send text once if enabled. + /// + public bool? EnableSsml { get; set; } +} diff --git a/src/Cnblogs.DashScope.Core/SpeechSynthesizerSocketSession.cs b/src/Cnblogs.DashScope.Core/SpeechSynthesizerSocketSession.cs new file mode 100644 index 0000000..a3ed0e4 --- /dev/null +++ b/src/Cnblogs.DashScope.Core/SpeechSynthesizerSocketSession.cs @@ -0,0 +1,156 @@ +namespace Cnblogs.DashScope.Core; + +/// +/// Represents a socket-based TTS session. +/// +public sealed class SpeechSynthesizerSocketSession + : IDisposable +{ + private readonly DashScopeClientWebSocketWrapper _socket; + private readonly string _modelId; + + /// + /// Represents a socket-based TTS session. + /// + /// Underlying websocket. + /// Model name to use. + public SpeechSynthesizerSocketSession(DashScopeClientWebSocketWrapper socket, string modelId) + { + _socket = socket; + _modelId = modelId; + } + + /// + /// Send a run-task command, use random GUID as taskId. + /// + /// Input parameters. + /// Input text. + /// The cancellation token to use. + /// The generated taskId. + public Task RunTaskAsync( + SpeechSynthesizerParameters parameters, + string? text = null, + CancellationToken cancellationToken = default) + { + return RunTaskAsync(Guid.NewGuid().ToString(), parameters, text, cancellationToken); + } + + /// + /// Send a run-task command. + /// + /// Unique taskId. + /// Input parameters. + /// Input text. + /// The cancellation token to use. + /// . + public async Task RunTaskAsync( + string taskId, + SpeechSynthesizerParameters parameters, + string? text = null, + CancellationToken cancellationToken = default) + { + var command = new DashScopeWebSocketRequest + { + Header = new DashScopeWebSocketRequestHeader + { + Action = "run-task", TaskId = taskId, + }, + Payload = new DashScopeWebSocketRequestPayload + { + Input = new SpeechSynthesizerInput { Text = text, }, + TaskGroup = "audio", + Task = "tts", + Function = "SpeechSynthesizer", + Model = _modelId, + Parameters = parameters + } + }; + + _socket.ResetTask(); + await _socket.SendMessageAsync(command, cancellationToken); + await _socket.TaskStarted; + return taskId; + } + + /// + /// Append input text to task. + /// + /// TaskId to append. + /// Text to append. + /// Cancellation token to use. + public async Task ContinueTaskAsync(string taskId, string input, CancellationToken cancellationToken = default) + { + var command = new DashScopeWebSocketRequest + { + Header = new DashScopeWebSocketRequestHeader + { + Action = "continue-task", + TaskId = taskId, + Streaming = null + }, + Payload = new DashScopeWebSocketRequestPayload + { + Input = new SpeechSynthesizerInput { Text = input } + } + }; + await _socket.SendMessageAsync(command, cancellationToken); + } + + /// + /// Send finish-task command. + /// + /// Unique id of the task. + /// The cancellation token to use. + public async Task FinishTaskAsync(string taskId, CancellationToken cancellationToken = default) + { + var command = new DashScopeWebSocketRequest + { + Header = new DashScopeWebSocketRequestHeader + { + TaskId = taskId, + Action = "finish-task", + Streaming = null + }, + Payload = new DashScopeWebSocketRequestPayload + { + Input = new SpeechSynthesizerInput() + } + }; + await _socket.SendMessageAsync(command, cancellationToken); + } + + /// + /// Get the audio stream. + /// + /// + public IAsyncEnumerable GetAudioAsync() + { + return _socket.BinaryOutput; + } + + /// + /// Get the message stream. + /// + /// + public async IAsyncEnumerable> GetMessagesAsync() + { + await foreach (var response in _socket.JsonOutput) + { + yield return response.DeserializeOutput(); + } + } + + private void Dispose(bool disposing) + { + if (disposing) + { + _socket.Dispose(); + } + } + + /// + public void Dispose() + { + Dispose(true); + } +} diff --git a/src/Cnblogs.DashScope.Core/TextChatMessage.cs b/src/Cnblogs.DashScope.Core/TextChatMessage.cs index f61a033..3a3bfa2 100644 --- a/src/Cnblogs.DashScope.Core/TextChatMessage.cs +++ b/src/Cnblogs.DashScope.Core/TextChatMessage.cs @@ -44,12 +44,12 @@ public TextChatMessage( string? reasoningContent = null, List? toolCalls = null) { - this.Role = role; - this.Content = content; - this.Name = name; - this.Partial = partial; - this.ReasoningContent = reasoningContent; - this.ToolCalls = toolCalls; + Role = role; + Content = content; + Name = name; + Partial = partial; + ReasoningContent = reasoningContent; + ToolCalls = toolCalls; } /// The role of this message. diff --git a/src/Cnblogs.DashScope.Core/TextGenerationChoice.cs b/src/Cnblogs.DashScope.Core/TextGenerationChoice.cs index 51eb4ac..faca9b9 100644 --- a/src/Cnblogs.DashScope.Core/TextGenerationChoice.cs +++ b/src/Cnblogs.DashScope.Core/TextGenerationChoice.cs @@ -14,4 +14,9 @@ public class TextGenerationChoice /// The generated message. /// public TextChatMessage Message { get; set; } = new(Array.Empty()); + + /// + /// Token array with log possibility info. + /// + public TextGenerationLogprobs? Logprobs { get; set; } } diff --git a/src/Cnblogs.DashScope.Core/TextGenerationLogprobContent.cs b/src/Cnblogs.DashScope.Core/TextGenerationLogprobContent.cs new file mode 100644 index 0000000..c8b8b7d --- /dev/null +++ b/src/Cnblogs.DashScope.Core/TextGenerationLogprobContent.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; +using Cnblogs.DashScope.Core.Internals; + +namespace Cnblogs.DashScope.Core; + +/// +/// Represents a possible choice of token. +/// +/// Token content. +/// Token content in UTF-8 byte array. +/// Possibility, null when it's too low. +/// The most possible alternatives. +public record TextGenerationLogprobContent( + string Token, + [property: JsonConverter(typeof(ByteArrayLiteralConvertor))] + byte[] Bytes, + float? Logprob, + List TopLogprobs); diff --git a/src/Cnblogs.DashScope.Core/TextGenerationLogprobs.cs b/src/Cnblogs.DashScope.Core/TextGenerationLogprobs.cs new file mode 100644 index 0000000..ba4a107 --- /dev/null +++ b/src/Cnblogs.DashScope.Core/TextGenerationLogprobs.cs @@ -0,0 +1,7 @@ +namespace Cnblogs.DashScope.Core; + +/// +/// Possibilities of token choices. +/// +/// The choices with their possibility. +public record TextGenerationLogprobs(List Content); diff --git a/src/Cnblogs.DashScope.Core/TextGenerationOutput.cs b/src/Cnblogs.DashScope.Core/TextGenerationOutput.cs index 7288b8a..d0d43d8 100644 --- a/src/Cnblogs.DashScope.Core/TextGenerationOutput.cs +++ b/src/Cnblogs.DashScope.Core/TextGenerationOutput.cs @@ -19,4 +19,9 @@ public class TextGenerationOutput /// Not null when . is "message". /// public List? Choices { get; set; } + + /// + /// Not null when . configured to show source. + /// + public TextGenerationWebSearchInfo? SearchInfo { get; set; } } diff --git a/src/Cnblogs.DashScope.Core/TextGenerationParameters.cs b/src/Cnblogs.DashScope.Core/TextGenerationParameters.cs index 49800ed..fc2fee9 100644 --- a/src/Cnblogs.DashScope.Core/TextGenerationParameters.cs +++ b/src/Cnblogs.DashScope.Core/TextGenerationParameters.cs @@ -38,9 +38,21 @@ public class TextGenerationParameters : ITextGenerationParameters /// public bool? EnableSearch { get; set; } + /// + public TextGenerationSearchOptions? SearchOptions { get; set; } + /// public bool? EnableThinking { get; set; } + /// + public int? ThinkingBudget { get; set; } + + /// + public bool? Logprobs { get; set; } + + /// + public int? TopLogprobs { get; set; } + /// public IEnumerable? Tools { get; set; } @@ -50,6 +62,9 @@ public class TextGenerationParameters : ITextGenerationParameters /// public bool? ParallelToolCalls { get; set; } + /// + public TextGenerationTranslationOptions? TranslationOptions { get; set; } + /// public bool? IncrementalOutput { get; set; } } diff --git a/src/Cnblogs.DashScope.Core/TextGenerationSearchOptions.cs b/src/Cnblogs.DashScope.Core/TextGenerationSearchOptions.cs new file mode 100644 index 0000000..d0d8cb4 --- /dev/null +++ b/src/Cnblogs.DashScope.Core/TextGenerationSearchOptions.cs @@ -0,0 +1,32 @@ +namespace Cnblogs.DashScope.Core; + +/// +/// Web search options +/// +public class TextGenerationSearchOptions +{ + /// + /// Show search result in response. Defaults to false. + /// + public bool? EnableSource { get; set; } + + /// + /// Include citation in output. Defaults to false. + /// + public bool? EnableCitation { get; set; } + + /// + /// Citation format. Defaults to "[<number>]" + /// + public string? CitationFormat { get; set; } + + /// + /// Force model to use web search. Defaults to false. + /// + public bool? ForcedSearch { get; set; } + + /// + /// How many search records should be provided to model. "standard" - 5 records. "pro" - 10 records. + /// + public string? SearchStrategy { get; set; } +} diff --git a/src/Cnblogs.DashScope.Core/TextGenerationTopLogprobContent.cs b/src/Cnblogs.DashScope.Core/TextGenerationTopLogprobContent.cs new file mode 100644 index 0000000..9f19187 --- /dev/null +++ b/src/Cnblogs.DashScope.Core/TextGenerationTopLogprobContent.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; +using Cnblogs.DashScope.Core.Internals; + +namespace Cnblogs.DashScope.Core; + +/// +/// Represents one choice of most possibility alternative tokens. +/// +/// The token content. +/// The token content in UTF-8 byte array. +/// Possibility, null when possibility is too low. +public record TextGenerationTopLogprobContent( + string Token, + [property: JsonConverter(typeof(ByteArrayLiteralConvertor))] + byte[] Bytes, + float? Logprob); diff --git a/src/Cnblogs.DashScope.Core/TextGenerationTranslationOptions.cs b/src/Cnblogs.DashScope.Core/TextGenerationTranslationOptions.cs new file mode 100644 index 0000000..d77f7c8 --- /dev/null +++ b/src/Cnblogs.DashScope.Core/TextGenerationTranslationOptions.cs @@ -0,0 +1,32 @@ +namespace Cnblogs.DashScope.Core; + +/// +/// Configurations when using translation models. +/// +public class TextGenerationTranslationOptions +{ + /// + /// The language name of the input text. Use 'auto' to enable auto-detection. + /// + public string SourceLang { get; set; } = "auto"; + + /// + /// The language name of the output text. + /// + public string TargetLang { get; set; } = string.Empty; + + /// + /// Term list for translation. + /// + public IEnumerable? Terms { get; set; } + + /// + /// Sample texts for translation + /// + public IEnumerable? TmList { get; set; } + + /// + /// Domain info about the source text. Only supports English. + /// + public string? Domains { get; set; } +} diff --git a/src/Cnblogs.DashScope.Core/TextGenerationWebSearchInfo.cs b/src/Cnblogs.DashScope.Core/TextGenerationWebSearchInfo.cs new file mode 100644 index 0000000..27da418 --- /dev/null +++ b/src/Cnblogs.DashScope.Core/TextGenerationWebSearchInfo.cs @@ -0,0 +1,7 @@ +namespace Cnblogs.DashScope.Core; + +/// +/// Web search information. +/// +/// Web search results. +public record TextGenerationWebSearchInfo(List SearchResults); diff --git a/src/Cnblogs.DashScope.Core/TextGenerationWebSearchResult.cs b/src/Cnblogs.DashScope.Core/TextGenerationWebSearchResult.cs new file mode 100644 index 0000000..ccdf085 --- /dev/null +++ b/src/Cnblogs.DashScope.Core/TextGenerationWebSearchResult.cs @@ -0,0 +1,11 @@ +namespace Cnblogs.DashScope.Core; + +/// +/// Represents one web search record. +/// +/// Source site name. +/// Source site favicon url. +/// Serial number of search records. +/// Page title. +/// Page url. +public record TextGenerationWebSearchResult(string SiteName, string Icon, int Index, string Title, string Url); diff --git a/src/Cnblogs.DashScope.Core/TranslationReference.cs b/src/Cnblogs.DashScope.Core/TranslationReference.cs new file mode 100644 index 0000000..256056a --- /dev/null +++ b/src/Cnblogs.DashScope.Core/TranslationReference.cs @@ -0,0 +1,8 @@ +namespace Cnblogs.DashScope.Core; + +/// +/// A text pair that used for translation reference. +/// +/// The text in source language. +/// The text in target language. +public record TranslationReference(string Source, string Target); diff --git a/src/Cnblogs.DashScope.Sdk/BaiChuan/BaiChuan2Llm.cs b/src/Cnblogs.DashScope.Sdk/BaiChuan/BaiChuan2Llm.cs deleted file mode 100644 index 8ee2af3..0000000 --- a/src/Cnblogs.DashScope.Sdk/BaiChuan/BaiChuan2Llm.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace Cnblogs.DashScope.Sdk.BaiChuan; - -/// -/// BaiChuan2 model, supports prompt and message format. -/// -public enum BaiChuan2Llm -{ - /// - /// baichuan2-7b-chat-v1 - /// - BaiChuan2_7BChatV1 = 1, - - /// - /// baichuan2-13b-chat-v1 - /// - BaiChuan2_13BChatV1 = 2 -} diff --git a/src/Cnblogs.DashScope.Sdk/BaiChuan/BaiChuanLlm.cs b/src/Cnblogs.DashScope.Sdk/BaiChuan/BaiChuanLlm.cs deleted file mode 100644 index 53c5d8a..0000000 --- a/src/Cnblogs.DashScope.Sdk/BaiChuan/BaiChuanLlm.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace Cnblogs.DashScope.Sdk.BaiChuan; - -/// -/// Supported baichuan model: https://help.aliyun.com/zh/dashscope/developer-reference/api-details-2 -/// -public enum BaiChuanLlm -{ - /// - /// baichuan-7b-v1 - /// - BaiChuan7B = 1 -} diff --git a/src/Cnblogs.DashScope.Sdk/BaiChuan/BaiChuanLlmName.cs b/src/Cnblogs.DashScope.Sdk/BaiChuan/BaiChuanLlmName.cs deleted file mode 100644 index b5ffff1..0000000 --- a/src/Cnblogs.DashScope.Sdk/BaiChuan/BaiChuanLlmName.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace Cnblogs.DashScope.Sdk.BaiChuan; - -internal static class BaiChuanLlmName -{ - public static string GetModelName(this BaiChuanLlm llm) - { - return llm switch - { - BaiChuanLlm.BaiChuan7B => "baichuan-7b-v1", - _ => ThrowHelper.UnknownModelName(nameof(llm), llm) - }; - } - - public static string GetModelName(this BaiChuan2Llm llm) - { - return llm switch - { - BaiChuan2Llm.BaiChuan2_7BChatV1 => "baichuan2-7b-chat-v1", - BaiChuan2Llm.BaiChuan2_13BChatV1 => "baichuan2-13b-chat-v1", - _ => ThrowHelper.UnknownModelName(nameof(llm), llm) - }; - } -} diff --git a/src/Cnblogs.DashScope.Sdk/BaiChuan/BaiChuanTextGenerationApi.cs b/src/Cnblogs.DashScope.Sdk/BaiChuan/BaiChuanTextGenerationApi.cs deleted file mode 100644 index ef3ec60..0000000 --- a/src/Cnblogs.DashScope.Sdk/BaiChuan/BaiChuanTextGenerationApi.cs +++ /dev/null @@ -1,87 +0,0 @@ -using Cnblogs.DashScope.Core; - -namespace Cnblogs.DashScope.Sdk.BaiChuan; - -/// -/// BaiChuan LLM generation apis, doc: https://help.aliyun.com/zh/dashscope/developer-reference/api-details-2 -/// -public static class BaiChuanTextGenerationApi -{ - /// - /// Get text completion from baichuan model. - /// - /// The . - /// The llm to use. - /// The prompt to generate completion from. - /// - public static Task> GetBaiChuanTextCompletionAsync( - this IDashScopeClient client, - BaiChuanLlm llm, - string prompt) - { - return client.GetBaiChuanTextCompletionAsync(llm.GetModelName(), prompt); - } - - /// - /// Get text completion from baichuan model. - /// - /// The . - /// The llm to use. - /// The prompt to generate completion from. - /// - public static Task> GetBaiChuanTextCompletionAsync( - this IDashScopeClient client, - string llm, - string prompt) - { - return client.GetTextCompletionAsync( - new ModelRequest - { - Model = llm, - Input = new TextGenerationInput { Prompt = prompt }, - Parameters = null - }); - } - - /// - /// Get text completion from baichuan model. - /// - /// The . - /// The model name. - /// The context messages. - /// Can be 'text' or 'message', defaults to 'text'. Call to get available options. - /// - public static Task> GetBaiChuanTextCompletionAsync( - this IDashScopeClient client, - BaiChuan2Llm llm, - IEnumerable messages, - string? resultFormat = null) - { - return client.GetBaiChuanTextCompletionAsync(llm.GetModelName(), messages, resultFormat); - } - - /// - /// Get text completion from baichuan model. - /// - /// The . - /// The model name. - /// The context messages. - /// Can be 'text' or 'message', defaults to 'text'. Call to get available options. - /// - public static Task> GetBaiChuanTextCompletionAsync( - this IDashScopeClient client, - string llm, - IEnumerable messages, - string? resultFormat = null) - { - return client.GetTextCompletionAsync( - new ModelRequest - { - Model = llm, - Input = new TextGenerationInput { Messages = messages }, - Parameters = string.IsNullOrEmpty(resultFormat) == false - ? new TextGenerationParameters { ResultFormat = resultFormat } - : null - }); - } -} diff --git a/src/Cnblogs.DashScope.Sdk/Llama2/Llama2Model.cs b/src/Cnblogs.DashScope.Sdk/Llama2/Llama2Model.cs deleted file mode 100644 index 827fd14..0000000 --- a/src/Cnblogs.DashScope.Sdk/Llama2/Llama2Model.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace Cnblogs.DashScope.Sdk.Llama2; - -/// -/// Supported models for LLaMa2. -/// -public enum Llama2Model -{ - /// - /// llama2-7b-chat-v2 - /// - Chat7Bv2 = 1, - - /// - /// llama2-13b-chat-v2 - /// - Chat13Bv2 = 2 -} diff --git a/src/Cnblogs.DashScope.Sdk/Llama2/Llama2ModelNames.cs b/src/Cnblogs.DashScope.Sdk/Llama2/Llama2ModelNames.cs deleted file mode 100644 index 44357d3..0000000 --- a/src/Cnblogs.DashScope.Sdk/Llama2/Llama2ModelNames.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace Cnblogs.DashScope.Sdk.Llama2; - -internal static class Llama2ModelNames -{ - public static string GetModelName(this Llama2Model model) - { - return model switch - { - Llama2Model.Chat7Bv2 => "llama2-7b-chat-v2", - Llama2Model.Chat13Bv2 => "llama2-13b-chat-v2", - _ => ThrowHelper.UnknownModelName(nameof(model), model) - }; - } -} diff --git a/src/Cnblogs.DashScope.Sdk/Llama2/Llama2TextGenerationApi.cs b/src/Cnblogs.DashScope.Sdk/Llama2/Llama2TextGenerationApi.cs deleted file mode 100644 index 5fa9b45..0000000 --- a/src/Cnblogs.DashScope.Sdk/Llama2/Llama2TextGenerationApi.cs +++ /dev/null @@ -1,53 +0,0 @@ -using Cnblogs.DashScope.Core; - -namespace Cnblogs.DashScope.Sdk.Llama2; - -/// -/// Extensions for llama2 text generation, docs: https://help.aliyun.com/zh/dashscope/developer-reference/api-details-11 -/// -public static class Llama2TextGenerationApi -{ - /// - /// Get text completion from llama2 model. - /// - /// The . - /// The model name. - /// The context messages. - /// Can be 'text' or 'message'. Call to get available options. - /// - public static async Task> - GetLlama2TextCompletionAsync( - this IDashScopeClient client, - Llama2Model model, - IEnumerable messages, - string? resultFormat = null) - { - return await client.GetLlama2TextCompletionAsync(model.GetModelName(), messages, resultFormat); - } - - /// - /// Get text completion from llama2 model. - /// - /// The . - /// The model name. - /// The context messages. - /// Can be 'text' or 'message'. Call to get available options. - /// - public static async Task> - GetLlama2TextCompletionAsync( - this IDashScopeClient client, - string model, - IEnumerable messages, - string? resultFormat = null) - { - return await client.GetTextCompletionAsync( - new ModelRequest - { - Model = model, - Input = new TextGenerationInput { Messages = messages }, - Parameters = resultFormat != null - ? new TextGenerationParameters { ResultFormat = resultFormat } - : null - }); - } -} diff --git a/src/Cnblogs.DashScope.Sdk/QWenMultimodal/QWenMultimodalModel.cs b/src/Cnblogs.DashScope.Sdk/QWenMultimodal/QWenMultimodalModel.cs index fa65480..abeeade 100644 --- a/src/Cnblogs.DashScope.Sdk/QWenMultimodal/QWenMultimodalModel.cs +++ b/src/Cnblogs.DashScope.Sdk/QWenMultimodal/QWenMultimodalModel.cs @@ -58,5 +58,25 @@ public enum QWenMultimodalModel /// /// qwen-audio-turbo-latest /// - QWenAudioTurboLatest = 11 + QWenAudioTurboLatest = 11, + + /// + /// qvq-max + /// + QvQMax = 12, + + /// + /// qvq-max-latest + /// + QvQMaxLatest = 13, + + /// + /// qvq-plus + /// + QvQPlus = 14, + + /// + /// qvq-plus-latest + /// + QvQPlusLatest = 15 } diff --git a/src/Cnblogs.DashScope.Sdk/QWenMultimodal/QWenMultimodalModelNames.cs b/src/Cnblogs.DashScope.Sdk/QWenMultimodal/QWenMultimodalModelNames.cs index b5bd02a..e22aeda 100644 --- a/src/Cnblogs.DashScope.Sdk/QWenMultimodal/QWenMultimodalModelNames.cs +++ b/src/Cnblogs.DashScope.Sdk/QWenMultimodal/QWenMultimodalModelNames.cs @@ -17,6 +17,10 @@ public static string GetModelName(this QWenMultimodalModel multimodalModel) QWenMultimodalModel.QWenVlPlusLatest => "qwen-vl-plus-latest", QWenMultimodalModel.QWenVlOcrLatest => "qwen-vl-ocr-latest", QWenMultimodalModel.QWenAudioTurboLatest => "qwen-audio-turbo-latest", + QWenMultimodalModel.QvQMax => "qvq-max", + QWenMultimodalModel.QvQMaxLatest => "qvq-max-latest", + QWenMultimodalModel.QvQPlus => "qvq-plus", + QWenMultimodalModel.QvQPlusLatest => "qvq-plus-latest", _ => ThrowHelper.UnknownModelName(nameof(multimodalModel), multimodalModel) }; } diff --git a/src/Cnblogs.DashScope.Sdk/TextEmbedding/TextEmbeddingModel.cs b/src/Cnblogs.DashScope.Sdk/TextEmbedding/TextEmbeddingModel.cs index 808db24..72af726 100644 --- a/src/Cnblogs.DashScope.Sdk/TextEmbedding/TextEmbeddingModel.cs +++ b/src/Cnblogs.DashScope.Sdk/TextEmbedding/TextEmbeddingModel.cs @@ -19,4 +19,9 @@ public enum TextEmbeddingModel /// text-embedding-v3 /// TextEmbeddingV3 = 3, + + /// + /// text-embedding-v4 + /// + TextEmbeddingV4 = 4 } diff --git a/src/Cnblogs.DashScope.Sdk/TextEmbedding/TextEmbeddingModelNames.cs b/src/Cnblogs.DashScope.Sdk/TextEmbedding/TextEmbeddingModelNames.cs index 410c33e..21d6b7d 100644 --- a/src/Cnblogs.DashScope.Sdk/TextEmbedding/TextEmbeddingModelNames.cs +++ b/src/Cnblogs.DashScope.Sdk/TextEmbedding/TextEmbeddingModelNames.cs @@ -9,6 +9,7 @@ public static string GetModelName(this TextEmbeddingModel model) TextEmbeddingModel.TextEmbeddingV1 => "text-embedding-v1", TextEmbeddingModel.TextEmbeddingV2 => "text-embedding-v2", TextEmbeddingModel.TextEmbeddingV3 => "text-embedding-v3", + TextEmbeddingModel.TextEmbeddingV4 => "text-embedding-v4", _ => ThrowHelper.UnknownModelName(nameof(model), model), }; } diff --git a/src/Cnblogs.DashScope.Sdk/Wanx/WanxModel.cs b/src/Cnblogs.DashScope.Sdk/Wanx/WanxModel.cs index f278eeb..3d19fdd 100644 --- a/src/Cnblogs.DashScope.Sdk/Wanx/WanxModel.cs +++ b/src/Cnblogs.DashScope.Sdk/Wanx/WanxModel.cs @@ -8,5 +8,20 @@ public enum WanxModel /// /// wanx-v1 /// - WanxV1 = 1 + WanxV1 = 1, + + /// + /// wanx2.1-t2i-plus + /// + WanxV21Plus = 2, + + /// + /// wanx2.1-t2i-turbo + /// + WanxV21Turbo = 3, + + /// + /// wanx2.0-t2i-turbo + /// + WanxV20Turbo = 4 } diff --git a/src/Cnblogs.DashScope.Sdk/Wanx/WanxModelNames.cs b/src/Cnblogs.DashScope.Sdk/Wanx/WanxModelNames.cs index ab04555..8e04751 100644 --- a/src/Cnblogs.DashScope.Sdk/Wanx/WanxModelNames.cs +++ b/src/Cnblogs.DashScope.Sdk/Wanx/WanxModelNames.cs @@ -7,6 +7,9 @@ public static string GetModelName(this WanxModel model) return model switch { WanxModel.WanxV1 => "wanx-v1", + WanxModel.WanxV21Plus => "wanx2.1-t2i-plus", + WanxModel.WanxV21Turbo => "wanx2.1-t2i-turbo", + WanxModel.WanxV20Turbo => "wanx2.0-t2i-turbo", _ => ThrowHelper.UnknownModelName(nameof(model), model) }; } diff --git a/test/Cnblogs.DashScope.Sdk.SnapshotGenerator/Cnblogs.DashScope.Sdk.SnapshotGenerator.csproj b/test/Cnblogs.DashScope.Sdk.SnapshotGenerator/Cnblogs.DashScope.Sdk.SnapshotGenerator.csproj deleted file mode 100644 index c23a53c..0000000 --- a/test/Cnblogs.DashScope.Sdk.SnapshotGenerator/Cnblogs.DashScope.Sdk.SnapshotGenerator.csproj +++ /dev/null @@ -1,13 +0,0 @@ - - - - net8.0 - Exe - false - - - - - - - diff --git a/test/Cnblogs.DashScope.Sdk.SnapshotGenerator/Program.cs b/test/Cnblogs.DashScope.Sdk.SnapshotGenerator/Program.cs deleted file mode 100644 index 83ba9e2..0000000 --- a/test/Cnblogs.DashScope.Sdk.SnapshotGenerator/Program.cs +++ /dev/null @@ -1,91 +0,0 @@ -using System.Net; -using System.Text; - -const string basePath = "../../../../Cnblogs.DashScope.Sdk.UnitTests/RawHttpData"; -var snapshots = new DirectoryInfo(basePath); -Console.WriteLine("Reading key from environment variable DASHSCOPE_KEY"); -var apiKey = Environment.GetEnvironmentVariable("DASHSCOPE_API_KEY"); -if (string.IsNullOrEmpty(apiKey)) -{ - Console.Write("ApiKey > "); - apiKey = Console.ReadLine(); -} - -var handler = new SocketsHttpHandler { AutomaticDecompression = DecompressionMethods.All, }; -var client = new HttpClient(handler) { BaseAddress = new Uri("https://dashscope.aliyuncs.com/api/v1/") }; -client.DefaultRequestHeaders.Add("Authorization", $"Bearer {apiKey}"); - -while (true) -{ - Console.Write("Snapshot Name > "); - var snapshotName = Console.ReadLine()?.Trim(); - if (string.IsNullOrEmpty(snapshotName)) - { - continue; - } - - var snapshot = snapshots.EnumerateFiles().Where(s => s.Name.StartsWith(snapshotName)) - .Select(s => s.Name.Split('.').First()).Distinct() - .ToList(); - if (snapshot.Count == 0) - { - Console.WriteLine($"No snapshot was found with name: {snapshotName}"); - } - - Console.WriteLine($"Updating {snapshot.Count} snapshots ..."); - foreach (var name in snapshot) - { - Console.WriteLine($"Updating {name}"); - await UpdateSnapshotsAsync(client, name); - Console.WriteLine($"{name} updated"); - } -} - -static async Task UpdateSnapshotsAsync(HttpClient client, string name) -{ - var requestHeader = await File.ReadAllLinesAsync(Path.Combine(basePath, $"{name}.request.header.txt")); - var requestBodyFile = Path.Combine(basePath, $"{name}.request.body.json"); - var requestBody = File.Exists(requestBodyFile) - ? await File.ReadAllTextAsync(Path.Combine(basePath, $"{name}.request.body.json")) - : string.Empty; - var firstLine = requestHeader[0].Split(' '); - var method = HttpMethod.Parse(firstLine[0]); - var request = new HttpRequestMessage(method, firstLine[1]); - var contentType = "application/json"; - foreach (var header in requestHeader.Skip(1)) - { - if (string.IsNullOrWhiteSpace(header)) - { - continue; - } - - var values = header.Split(':', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); - if (values[0] == "Content-Type") - { - contentType = values[1]; - continue; - } - - if (values[0] == "Content-Length") - { - continue; - } - - request.Headers.Add(values[0], values[1]); - } - - if (string.IsNullOrWhiteSpace(requestBodyFile) == false) - { - request.Content = new StringContent(requestBody, Encoding.Default, contentType); - } - - var response = await client.SendAsync(request); - var responseBody = await response.Content.ReadAsStringAsync(); - var responseHeaderFile = new StringBuilder(); - responseHeaderFile.AppendLine($"HTTP/1.1 {(int)response.StatusCode} {response.StatusCode}"); - responseHeaderFile = response.Headers.Aggregate( - responseHeaderFile, - (sb, pair) => sb.AppendLine($"{pair.Key}: {string.Join(',', pair.Value)}")); - await File.WriteAllTextAsync(Path.Combine(basePath, $"{name}.response.header.txt"), responseHeaderFile.ToString()); - await File.WriteAllTextAsync(Path.Combine(basePath, $"{name}.response.body.txt"), responseBody); -} diff --git a/test/Cnblogs.DashScope.Sdk.UnitTests/BaiChuanApiTests.cs b/test/Cnblogs.DashScope.Sdk.UnitTests/BaiChuanApiTests.cs deleted file mode 100644 index 0d99cac..0000000 --- a/test/Cnblogs.DashScope.Sdk.UnitTests/BaiChuanApiTests.cs +++ /dev/null @@ -1,81 +0,0 @@ -using Cnblogs.DashScope.Core; -using Cnblogs.DashScope.Sdk.BaiChuan; -using Cnblogs.DashScope.Tests.Shared.Utils; -using NSubstitute; - -namespace Cnblogs.DashScope.Sdk.UnitTests; - -public class BaiChuanApiTests -{ - [Fact] - public async Task BaiChuanTextGeneration_UseEnum_SuccessAsync() - { - // Arrange - var client = Substitute.For(); - - // Act - _ = await client.GetBaiChuanTextCompletionAsync(BaiChuanLlm.BaiChuan7B, Cases.Prompt); - - // Assert - _ = await client.Received().GetTextCompletionAsync( - Arg.Is>( - s => s.Model == "baichuan-7b-v1" && s.Input.Prompt == Cases.Prompt && s.Parameters == null)); - } - - [Fact] - public async Task BaiChuanTextGeneration_CustomModel_SuccessAsync() - { - // Arrange - var client = Substitute.For(); - - // Act - _ = await client.GetBaiChuanTextCompletionAsync(BaiChuanLlm.BaiChuan7B, Cases.Prompt); - - // Assert - _ = await client.Received().GetTextCompletionAsync( - Arg.Is>( - s => s.Model == "baichuan-7b-v1" && s.Input.Prompt == Cases.Prompt && s.Parameters == null)); - } - - [Fact] - public async Task BaiChuan2TextGeneration_UseEnum_SuccessAsync() - { - // Arrange - var client = Substitute.For(); - - // Act - _ = await client.GetBaiChuanTextCompletionAsync( - BaiChuan2Llm.BaiChuan2_13BChatV1, - Cases.TextMessages, - ResultFormats.Message); - - // Assert - _ = await client.Received().GetTextCompletionAsync( - Arg.Is>( - s => s.Model == "baichuan2-13b-chat-v1" - && s.Input.Messages == Cases.TextMessages - && s.Parameters != null - && s.Parameters.ResultFormat == ResultFormats.Message)); - } - - [Fact] - public async Task BaiChuan2TextGeneration_CustomModel_SuccessAsync() - { - // Arrange - var client = Substitute.For(); - - // Act - _ = await client.GetBaiChuanTextCompletionAsync( - Cases.CustomModelName, - Cases.TextMessages, - ResultFormats.Message); - - // Assert - _ = await client.Received().GetTextCompletionAsync( - Arg.Is>( - s => s.Model == Cases.CustomModelName - && s.Input.Messages == Cases.TextMessages - && s.Parameters != null - && s.Parameters.ResultFormat == ResultFormats.Message)); - } -} diff --git a/test/Cnblogs.DashScope.Sdk.UnitTests/Cnblogs.DashScope.Sdk.UnitTests.csproj b/test/Cnblogs.DashScope.Sdk.UnitTests/Cnblogs.DashScope.Sdk.UnitTests.csproj index 94a3a5e..3fbb8f0 100644 --- a/test/Cnblogs.DashScope.Sdk.UnitTests/Cnblogs.DashScope.Sdk.UnitTests.csproj +++ b/test/Cnblogs.DashScope.Sdk.UnitTests/Cnblogs.DashScope.Sdk.UnitTests.csproj @@ -11,7 +11,6 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - diff --git a/test/Cnblogs.DashScope.Sdk.UnitTests/DashScopeClientTests.cs b/test/Cnblogs.DashScope.Sdk.UnitTests/DashScopeClientTests.cs index 93f7d4d..7b3aab5 100644 --- a/test/Cnblogs.DashScope.Sdk.UnitTests/DashScopeClientTests.cs +++ b/test/Cnblogs.DashScope.Sdk.UnitTests/DashScopeClientTests.cs @@ -81,7 +81,7 @@ public void DashScopeClient_Constructor_WithWorkspaceId() // Arrange const string apiKey = "key"; const string workspaceId = "workspaceId"; - var client = new DashScopeClient(apiKey, null, null, workspaceId); + var client = new DashScopeClient(apiKey, workspaceId: workspaceId); // Act var value = HttpClientAccessor.GetValue(client) as HttpClient; diff --git a/test/Cnblogs.DashScope.Sdk.UnitTests/DashScopeClientWebSocketFactoryTests.cs b/test/Cnblogs.DashScope.Sdk.UnitTests/DashScopeClientWebSocketFactoryTests.cs new file mode 100644 index 0000000..4d13328 --- /dev/null +++ b/test/Cnblogs.DashScope.Sdk.UnitTests/DashScopeClientWebSocketFactoryTests.cs @@ -0,0 +1,41 @@ +using System.Net; +using System.Net.WebSockets; +using System.Reflection; +using Cnblogs.DashScope.Core; +using Cnblogs.DashScope.Core.Internals; + +namespace Cnblogs.DashScope.Sdk.UnitTests; + +public class DashScopeClientWebSocketFactoryTests +{ + private static readonly FieldInfo ClientWebSocketWrapperGetter = + typeof(DashScopeClientWebSocket).GetField("_socket", BindingFlags.NonPublic | BindingFlags.Instance)!; + + private static readonly FieldInfo ClientWebSocketGetter = + typeof(ClientWebSocketWrapper).GetField("_socket", BindingFlags.NonPublic | BindingFlags.Instance)!; + + private static readonly PropertyInfo RequestHeaderGetter = typeof(ClientWebSocketOptions).GetProperty( + "RequestHeaders", + BindingFlags.NonPublic | BindingFlags.Instance)!; + + [Fact] + public void CreateSocket_WithWorkspaceId_SetKeyAndSpaceIdProperly() + { + // Arrange + const string apiKey = "apikey"; + const string workspaceId = "some-space"; + var factory = new DashScopeClientWebSocketFactory(); + + // Act + var socket = factory.GetClientWebSocket(apiKey, workspaceId); + var socketWrapper = ClientWebSocketWrapperGetter.GetValue(socket) as ClientWebSocketWrapper; + var clientWebSocket = ClientWebSocketGetter.GetValue(socketWrapper) as ClientWebSocket; + var headers = RequestHeaderGetter.GetValue(clientWebSocket?.Options) as WebHeaderCollection; + + // Assert + Assert.NotNull(socketWrapper); + Assert.NotNull(headers); + Assert.Equal("bearer " + apiKey, headers.Get("Authorization")); + Assert.Equal(workspaceId, headers.Get("X-DashScope-WorkspaceId")); + } +} diff --git a/test/Cnblogs.DashScope.Sdk.UnitTests/DashScopeClientWebSocketPoolTests.cs b/test/Cnblogs.DashScope.Sdk.UnitTests/DashScopeClientWebSocketPoolTests.cs new file mode 100644 index 0000000..e484586 --- /dev/null +++ b/test/Cnblogs.DashScope.Sdk.UnitTests/DashScopeClientWebSocketPoolTests.cs @@ -0,0 +1,160 @@ +using Cnblogs.DashScope.Core; +using Cnblogs.DashScope.Tests.Shared.Utils; +using NSubstitute; +using NSubstitute.Extensions; + +namespace Cnblogs.DashScope.Sdk.UnitTests; + +public class DashScopeClientWebSocketPoolTests +{ + [Fact] + public async Task RentSocket_PoolIsEmpty_CreateAsync() + { + // Arrange + var option = new DashScopeOptions(); + var factory = Substitute.For(); + factory.Configure().GetClientWebSocket(Arg.Any(), Arg.Any()) + .Returns(new DashScopeClientWebSocket(new FakeClientWebSocket())); + var pool = new DashScopeClientWebSocketPool(factory, option); + + // Act + var socket = await pool.RentSocketAsync(); + + // Assert + Assert.Equal(DashScopeWebSocketState.Ready, socket.Socket.State); + } + + [Fact] + public async Task RentSocket_HasAvailableSocket_ReturnAsync() + { + // Arrange + var option = new DashScopeOptions(); + var factory = Substitute.For(); + factory.Configure().GetClientWebSocket(Arg.Any(), Arg.Any()) + .Returns(new DashScopeClientWebSocket(new FakeClientWebSocket())); + var readySocket = factory.GetClientWebSocket(string.Empty); + await readySocket.ConnectAsync(new Uri(option.WebsocketBaseAddress)); + var pool = new DashScopeClientWebSocketPool(new[] { readySocket }, factory); + + // Act + var socket = await pool.RentSocketAsync(); + + // Assert + Assert.Equal(DashScopeWebSocketState.Ready, socket.Socket.State); + Assert.StrictEqual(readySocket, socket.Socket); + } + + [Fact] + public async Task RentSocket_SocketExpired_DisposeAndMoveNext() + { + // Arrange + var factory = Substitute.For(); + factory.Configure().GetClientWebSocket(Arg.Any(), Arg.Any()) + .Returns(_ => new DashScopeClientWebSocket(new FakeClientWebSocket())); + + var fakeSocket = new FakeClientWebSocket(); + var closedSocket = new DashScopeClientWebSocket(fakeSocket); + await closedSocket.CloseAsync(); + var pool = new DashScopeClientWebSocketPool(new[] { closedSocket }, factory); + + // Act + var socket = await pool.RentSocketAsync(); + + // Assert + Assert.Equal(DashScopeWebSocketState.Ready, socket.Socket.State); + Assert.NotStrictEqual(closedSocket, socket.Socket); + Assert.True(fakeSocket.DisposeCalled); + } + + [Fact] + public async Task RentSocket_PoolStarvation_ThrowAsync() + { + // Arrange + var option = new DashScopeOptions { SocketPoolSize = 3 }; + var factory = Substitute.For(); + factory.Configure().GetClientWebSocket(Arg.Any(), Arg.Any()) + .Returns(_ => new DashScopeClientWebSocket(new FakeClientWebSocket())); + var pool = new DashScopeClientWebSocketPool(factory, option); + await Task.WhenAll(Enumerable.Range(0, option.SocketPoolSize).Select(async _ => await pool.RentSocketAsync())); + + // Act + var act = async () => await pool.RentSocketAsync(); + + // Assert + await Assert.ThrowsAsync(act); + } + + [Fact] + public async Task Dispose_ManuallyDispose_DisposeAllPooledSocketsAsync() + { + // Arrange + var option = new DashScopeOptions(); + var factory = Substitute.For(); + factory.Configure().GetClientWebSocket(Arg.Any(), Arg.Any()) + .Returns(_ => new DashScopeClientWebSocket(new FakeClientWebSocket())); + var fake1 = new FakeClientWebSocket(); + var fake2 = new FakeClientWebSocket(); + var s1 = new DashScopeClientWebSocket(fake1); + var s2 = new DashScopeClientWebSocket(fake2); + var sockets = new[] { s1, s2 }; + foreach (var socket in sockets) + { + await socket.ConnectAsync(new Uri(option.WebsocketBaseAddress)); + } + + var pool = new DashScopeClientWebSocketPool(sockets, factory); + + // Act + var active = await pool.RentSocketAsync(); + pool.Dispose(); + + // Assert + Assert.NotNull(active); + Assert.True(fake1.DisposeCalled); + Assert.True(fake2.DisposeCalled); + } + + [Fact] + public async Task ReturnSocket_SocketNotReady_DisposeAsync() + { + // Arrange + var option = new DashScopeOptions(); + var fakeSocket = new FakeClientWebSocket(); + var factory = Substitute.For(); + factory.Configure().GetClientWebSocket(Arg.Any(), Arg.Any()) + .Returns(new DashScopeClientWebSocket(fakeSocket)); + var pool = new DashScopeClientWebSocketPool(factory, option); + + // Act + var socket = await pool.RentSocketAsync(); + await fakeSocket.WriteServerCloseAsync(); + pool.ReturnSocket(socket.Socket); + + // Assert + Assert.Equal(0, pool.ActiveSocketCount); + Assert.Equal(0, pool.AvailableSocketCount); + Assert.Equal(DashScopeWebSocketState.Closed, socket.Socket.State); + Assert.True(fakeSocket.DisposeCalled); + } + + [Fact] + public async Task ReturnSocket_SocketReady_SaveAsync() + { + // Arrange + var option = new DashScopeOptions(); + var fakeSocket = new FakeClientWebSocket(); + var factory = Substitute.For(); + factory.Configure().GetClientWebSocket(Arg.Any(), Arg.Any()) + .Returns(new DashScopeClientWebSocket(fakeSocket)); + var pool = new DashScopeClientWebSocketPool(factory, option); + + // Act + var socket = await pool.RentSocketAsync(); + pool.ReturnSocket(socket.Socket); + + // Assert + Assert.Equal(1, pool.AvailableSocketCount); + Assert.Equal(0, pool.ActiveSocketCount); + Assert.False(fakeSocket.DisposeCalled); + } +} diff --git a/test/Cnblogs.DashScope.Sdk.UnitTests/DashScopeClientWebSocketTests.cs b/test/Cnblogs.DashScope.Sdk.UnitTests/DashScopeClientWebSocketTests.cs new file mode 100644 index 0000000..5f789c2 --- /dev/null +++ b/test/Cnblogs.DashScope.Sdk.UnitTests/DashScopeClientWebSocketTests.cs @@ -0,0 +1,277 @@ +using System.Net; +using System.Net.WebSockets; +using System.Reflection; +using Cnblogs.DashScope.Core; +using Cnblogs.DashScope.Core.Internals; +using Cnblogs.DashScope.Tests.Shared.Utils; +using NSubstitute; + +namespace Cnblogs.DashScope.Sdk.UnitTests; + +[Collection(nameof(SocketTestsCollection))] +public class DashScopeClientWebSocketTests +{ + private static readonly FieldInfo InnerSocketInfo = + typeof(DashScopeClientWebSocket).GetField("_socket", BindingFlags.NonPublic | BindingFlags.Instance) + ?? throw new InvalidOperationException( + $"Can not found {nameof(DashScopeClientWebSocket)}._client, please update this test after refactoring"); + + private static readonly PropertyInfo InnerRequestHeaderInfo = + typeof(ClientWebSocketOptions).GetProperty("RequestHeaders", BindingFlags.NonPublic | BindingFlags.Instance) + ?? throw new InvalidOperationException( + $"Can not found {nameof(ClientWebSocketOptions)}.RequestHeaders property, please update this test after framework change"); + + [Fact] + public void Constructor_UseApiKeyAndWorkspaceId_EnsureConfigured() + { + // Arrange + const string apiKey = "apiKey"; + const string workspaceId = "workspaceId"; + + // Act + var client = new DashScopeClientWebSocket(apiKey, workspaceId); + var headers = ExtractHeaders(client); + + // Assert + Assert.Equal($"bearer {apiKey}", headers.GetValues("Authorization")?.First()); + Assert.Equal("enable", headers.GetValues("X-DashScope-DataInspection")?.First()); + Assert.Equal(workspaceId, headers.GetValues("X-DashScope-WorkspaceId")?.First()); + } + + [Fact] + public void Constructor_UseApiKeyWithoutWorkspaceId_EnsureConfigured() + { + // Arrange + const string apiKey = "apiKey"; + + // Act + var client = new DashScopeClientWebSocket(apiKey); + var headers = ExtractHeaders(client); + + // Assert + Assert.Equal($"bearer {apiKey}", headers.GetValues("Authorization")?.First()); + Assert.Equal("enable", headers.GetValues("X-DashScope-DataInspection")?.First()); + Assert.Null(headers.GetValues("X-DashScope-WorkspaceId")); + } + + [Fact] + public void Constructor_UsePreconfiguredSocket_EnsureConfigured() + { + // Arrange + using var socket = new ClientWebSocketWrapper(new ClientWebSocket()); + + // Act + var client = new DashScopeClientWebSocket(socket); + + // Assert + Assert.StrictEqual(socket, InnerSocketInfo.GetValue(client)); + } + + [Fact] + public async Task ConnectAsync_InitialConnect_ChangeStateAsync() + { + // Arrange + var socket = Substitute.For(); + var client = new DashScopeClientWebSocket(socket); + var apiUri = new Uri("ws://test.com"); + + // Act + await client.ConnectAsync(apiUri); + + // Assert + Assert.Equal(DashScopeWebSocketState.Ready, client.State); + await socket.Received(1).ConnectAsync(Arg.Is(apiUri), Arg.Any()); + await socket.Received().ReceiveAsync(Arg.Any>(), Arg.Any()); + } + + [Fact] + public async Task ResetOutput_WithInitialOutput_CompleteThenCreateNewOutputAsync() + { + // Arrange + var socket = Substitute.For(); + var client = new DashScopeClientWebSocket(socket); + client.ResetOutput(); + var oldBinary = client.BinaryOutput; + var oldJson = client.JsonOutput; + var oldSignal = client.TaskStarted; + + // Act + client.ResetOutput(); + + // Assert + Assert.False(await oldSignal); + Assert.True(oldBinary.Completion.IsCompletedSuccessfully); + Assert.NotSame(oldBinary, client.BinaryOutput); + Assert.True(oldJson.Completion.IsCompletedSuccessfully); + Assert.NotSame(oldJson, client.JsonOutput); + Assert.NotSame(oldSignal, client.TaskStarted); + } + + [Fact] + public async Task SendMessageAsync_SocketClosed_ThrowAsync() + { + // Arrange + var socket = Substitute.For(); + var client = new DashScopeClientWebSocket(socket); + var snapshot = Snapshots.SpeechSynthesizer.RunTask; + await client.CloseAsync(); + + // Act + var act = () => client.SendMessageAsync(snapshot.Message); + + // Assert + await Assert.ThrowsAsync(act); + } + + [Fact] + public async Task SendMessageAsync_Connected_SendAsync() + { + // Arrange + var socket = Substitute.For(); + var client = new DashScopeClientWebSocket(socket); + var snapshot = Snapshots.SpeechSynthesizer.RunTask; + + // Act + await client.ConnectAsync(new Uri(DashScopeDefaults.WebsocketApiBaseAddress)); + await client.SendMessageAsync(snapshot.Message); + + // Assert + await socket.Received().SendAsync( + Arg.Is>(s => Checkers.IsJsonEquivalent(s, snapshot.GetMessageJson())), + WebSocketMessageType.Text, + true, + Arg.Any()); + } + + [Fact] + public async Task ReceiveMessageAsync_ServerClosed_CloseAsync() + { + // Arrange + var (_, dashScopeClientWebSocket, server) = await Sut.GetSocketTestClientAsync(); + + // Act + await server.WriteServerCloseAsync(); + + // Assert + Assert.Equal(DashScopeWebSocketState.Closed, dashScopeClientWebSocket.State); + Assert.Equal(WebSocketCloseStatus.NormalClosure, server.CloseStatus); + } + + [Fact] + public async Task ReceiveMessageAsync_TaskStarted_UpdateStateToRunningAsync() + { + // Arrange + var (_, clientWebSocket, server) = await Sut.GetSocketTestClientAsync(); + var snapshot = Snapshots.SpeechSynthesizer.TaskStarted; + var taskStarted = clientWebSocket.TaskStarted; + + // Act + await server.WriteServerMessageAsync(snapshot.GetMessageJson()); + var timeout = Task.Delay(2000); // socket handles message in other thread, wait for it. + var any = await Task.WhenAny(timeout, taskStarted); + + // Assert + Assert.Equal(any, taskStarted); + Assert.Equal(DashScopeWebSocketState.RunningTask, clientWebSocket.State); + } + + [Fact] + public async Task ReceiveMessageAsync_TaskFinished_UpdateStateToReadyAsync() + { + // Arrange + var (_, clientWebSocket, server) = await Sut.GetSocketTestClientAsync(); + await server.WriteServerMessageAsync(Snapshots.SpeechSynthesizer.TaskStarted.GetMessageJson()); + await clientWebSocket.TaskStarted; + var snapshot = Snapshots.SpeechSynthesizer.TaskFinished; + var binaryOutput = clientWebSocket.BinaryOutput; + var jsonOutput = clientWebSocket.JsonOutput; + + // Act + await server.WriteServerMessageAsync(snapshot.GetMessageJson()); + var json = await jsonOutput.ReadAllAsync().ToListAsync(); + + // Assert + Assert.True(binaryOutput.Completion.IsCompleted); + Assert.True(jsonOutput.Completion.IsCompleted); + Assert.Equal(2, json.Count); + Assert.Equal(DashScopeWebSocketState.Ready, clientWebSocket.State); + } + + [Fact] + public async Task ReceiveMessageAsync_TaskFailed_CloseAndThrowAsync() + { + // Arrange + var (_, clientWebSocket, server) = await Sut.GetSocketTestClientAsync(); + await server.WriteServerMessageAsync(Snapshots.SpeechSynthesizer.TaskStarted.GetMessageJson()); + await clientWebSocket.TaskStarted; + var taskFailed = Snapshots.SpeechSynthesizer.TaskFailed; + var binary = clientWebSocket.BinaryOutput; + var json = clientWebSocket.JsonOutput; + + // Act + await server.WriteServerMessageAsync(taskFailed.GetMessageJson()); + await server.WriteServerCloseAsync(); + var messages = await json.ReadAllAsync().ToListAsync(); + + // Assert + Assert.True(binary.Completion.IsCompleted); + Assert.True(json.Completion.IsCompleted); + Assert.Equal(2, messages.Count); + Assert.Equal(DashScopeWebSocketState.Closed, clientWebSocket.State); + } + + [Fact] + public async Task ReceiveMessageAsync_ReceiveBinary_WriteToBinaryOutputAsync() + { + // Arrange + var (_, clientWebSocket, server) = await Sut.GetSocketTestClientAsync(); + await server.WriteServerMessageAsync(Snapshots.SpeechSynthesizer.TaskStarted.GetMessageJson()); + await clientWebSocket.TaskStarted; + var expectedAudio = Snapshots.SpeechSynthesizer.AudioTts; + var output = clientWebSocket.BinaryOutput; + var audioTask = output.ReadAllAsync().ToArrayAsync(); + + // Act + await server.WriteServerMessageAsync(expectedAudio); + await server.WriteServerMessageAsync(Snapshots.SpeechSynthesizer.TaskFinished.GetMessageJson()); + var audio = await audioTask; + + // Assert + Assert.True(output.Completion.IsCompleted); + Assert.Equal(expectedAudio, audio); + Assert.Equal(DashScopeWebSocketState.Ready, clientWebSocket.State); + } + + [Fact] + public async Task Dispose_ManuallyCalled_DisposeSocketAndOutputTogetherAsync() + { + // Arrange + var (_, clientWebSocket, server) = await Sut.GetSocketTestClientAsync(); + var output = clientWebSocket.BinaryOutput; + + // Act + clientWebSocket.Dispose(); + + // Assert + Assert.True(output.Completion.IsCompleted); + Assert.True(server.DisposeCalled); + } + + private static WebHeaderCollection ExtractHeaders(DashScopeClientWebSocket socket) + { + var obj = InnerSocketInfo.GetValue(socket); + if (obj is not IClientWebSocket clientWebSocket) + { + throw new InvalidOperationException($"Get null when trying to fetch {InnerSocketInfo.Name}"); + } + + obj = InnerRequestHeaderInfo.GetValue(clientWebSocket.Options); + if (obj is not WebHeaderCollection headers) + { + throw new InvalidOperationException( + $"Wrong type or null when trying to fetch {InnerRequestHeaderInfo.Name}"); + } + + return headers; + } +} diff --git a/test/Cnblogs.DashScope.Sdk.UnitTests/DashScopeClientWebSocketWrapperTests.cs b/test/Cnblogs.DashScope.Sdk.UnitTests/DashScopeClientWebSocketWrapperTests.cs new file mode 100644 index 0000000..5d1ce82 --- /dev/null +++ b/test/Cnblogs.DashScope.Sdk.UnitTests/DashScopeClientWebSocketWrapperTests.cs @@ -0,0 +1,31 @@ +using Cnblogs.DashScope.Core; +using Cnblogs.DashScope.Tests.Shared.Utils; +using NSubstitute; +using NSubstitute.Extensions; +using Xunit.Abstractions; + +namespace Cnblogs.DashScope.Sdk.UnitTests; + +public class DashScopeClientWebSocketWrapperTests +{ + [Fact] + public async Task Dispose_CallDispose_ReturnSocketToPoolAsync() + { + // Arrange + var option = new DashScopeOptions(); + var fakeSocket = new FakeClientWebSocket(); + var factory = Substitute.For(); + factory.Configure().GetClientWebSocket(Arg.Any(), Arg.Any()) + .Returns(new DashScopeClientWebSocket(fakeSocket)); + var pool = new DashScopeClientWebSocketPool(factory, option); + + // Act + var socket = await pool.RentSocketAsync(); + socket.Dispose(); + + // Assert + Assert.Equal(1, pool.AvailableSocketCount); + Assert.Equal(0, pool.ActiveSocketCount); + Assert.False(fakeSocket.DisposeCalled); + } +} diff --git a/test/Cnblogs.DashScope.Sdk.UnitTests/DeepSeekTextGenerationApiTests.cs b/test/Cnblogs.DashScope.Sdk.UnitTests/DeepSeekTextGenerationApiTests.cs index 461b037..87643dd 100644 --- a/test/Cnblogs.DashScope.Sdk.UnitTests/DeepSeekTextGenerationApiTests.cs +++ b/test/Cnblogs.DashScope.Sdk.UnitTests/DeepSeekTextGenerationApiTests.cs @@ -19,8 +19,23 @@ await client.GetDeepSeekChatCompletionAsync( // Assert await client.Received().GetTextCompletionAsync( - Arg.Is>( - x => x.Model == "deepseek-r1" && x.Input.Messages!.First().Content == "你好" && x.Parameters == null)); + Arg.Is>(x + => x.Model == "deepseek-r1" && x.Input.Messages!.First().Content == "你好" && x.Parameters == null)); + } + + [Fact] + public async Task TextCompletion_UseInvalidEnum_SuccessAsync() + { + // Arrange + var client = Substitute.For(); + + // Act + var act = async () => await client.GetDeepSeekChatCompletionAsync( + (DeepSeekLlm)(-1), + new List { TextChatMessage.User("你好") }.AsReadOnly()); + + // Assert + await Assert.ThrowsAsync(act); } [Fact] @@ -37,8 +52,8 @@ await client.GetDeepSeekChatCompletionAsync( // Assert await client.Received().GetTextCompletionAsync( - Arg.Is>( - x => x.Model == customModel && x.Input.Messages!.First().Content == "你好" && x.Parameters == null)); + Arg.Is>(x + => x.Model == customModel && x.Input.Messages!.First().Content == "你好" && x.Parameters == null)); } [Fact] @@ -54,10 +69,9 @@ public void StreamCompletion_UseEnum_SuccessAsync() // Assert _ = client.Received().GetTextCompletionStreamAsync( - Arg.Is>( - x => x.Model == "deepseek-v3" - && x.Input.Messages!.First().Content == "你好" - && x.Parameters!.IncrementalOutput == true)); + Arg.Is>(x => x.Model == "deepseek-v3" + && x.Input.Messages!.First().Content == "你好" + && x.Parameters!.IncrementalOutput == true)); } [Fact] @@ -74,9 +88,8 @@ public void StreamCompletion_CustomModel_SuccessAsync() // Assert _ = client.Received().GetTextCompletionStreamAsync( - Arg.Is>( - x => x.Model == customModel - && x.Input.Messages!.First().Content == "你好" - && x.Parameters!.IncrementalOutput == true)); + Arg.Is>(x => x.Model == customModel + && x.Input.Messages!.First().Content == "你好" + && x.Parameters!.IncrementalOutput == true)); } } diff --git a/test/Cnblogs.DashScope.Sdk.UnitTests/Llama2TextGenerationApiTests.cs b/test/Cnblogs.DashScope.Sdk.UnitTests/Llama2TextGenerationApiTests.cs deleted file mode 100644 index c4d5679..0000000 --- a/test/Cnblogs.DashScope.Sdk.UnitTests/Llama2TextGenerationApiTests.cs +++ /dev/null @@ -1,45 +0,0 @@ -using Cnblogs.DashScope.Core; -using Cnblogs.DashScope.Sdk.Llama2; -using Cnblogs.DashScope.Tests.Shared.Utils; -using NSubstitute; - -namespace Cnblogs.DashScope.Sdk.UnitTests; - -public class Llama2TextGenerationApiTests -{ - [Fact] - public async Task Llama2_UseEnum_SuccessAsync() - { - // Arrange - var client = Substitute.For(); - - // Act - _ = await client.GetLlama2TextCompletionAsync(Llama2Model.Chat13Bv2, Cases.TextMessages, ResultFormats.Message); - - // Assert - _ = await client.Received().GetTextCompletionAsync( - Arg.Is>( - s => s.Input.Messages == Cases.TextMessages - && s.Model == "llama2-13b-chat-v2" - && s.Parameters != null - && s.Parameters.ResultFormat == ResultFormats.Message)); - } - - [Fact] - public async Task Llama2_CustomModel_SuccessAsync() - { - // Arrange - var client = Substitute.For(); - - // Act - _ = await client.GetLlama2TextCompletionAsync(Cases.CustomModelName, Cases.TextMessages, ResultFormats.Message); - - // Assert - _ = await client.Received().GetTextCompletionAsync( - Arg.Is>( - s => s.Input.Messages == Cases.TextMessages - && s.Model == Cases.CustomModelName - && s.Parameters != null - && s.Parameters.ResultFormat == ResultFormats.Message)); - } -} diff --git a/test/Cnblogs.DashScope.Sdk.UnitTests/QWenMultimodalApiTests.cs b/test/Cnblogs.DashScope.Sdk.UnitTests/QWenMultimodalApiTests.cs index c2c15ce..8e7a553 100644 --- a/test/Cnblogs.DashScope.Sdk.UnitTests/QWenMultimodalApiTests.cs +++ b/test/Cnblogs.DashScope.Sdk.UnitTests/QWenMultimodalApiTests.cs @@ -30,8 +30,23 @@ public async Task Multimodal_UseEnum_SuccessAsync() // Assert _ = client.Received().GetMultimodalGenerationAsync( - Arg.Is>( - s => s.Model == "qwen-vl-max" && s.Input.Messages == Messages && s.Parameters == parameters)); + Arg.Is>(s + => s.Model == "qwen-vl-max" && s.Input.Messages == Messages && s.Parameters == parameters)); + } + + [Fact] + public async Task Multimodal_UseInvalidEnum_SuccessAsync() + { + // Arrange + var client = Substitute.For(); + var parameters = new MultimodalParameters { Seed = 6666 }; + + // Act + var act = async () + => await client.GetQWenMultimodalCompletionAsync((QWenMultimodalModel)(-1), Messages, parameters); + + // Assert + await Assert.ThrowsAsync(act); } [Fact] @@ -46,8 +61,8 @@ public async Task Multimodal_CustomModel_SuccessAsync() // Assert _ = client.Received().GetMultimodalGenerationAsync( - Arg.Is>( - s => s.Model == Cases.CustomModelName && s.Input.Messages == Messages && s.Parameters == parameters)); + Arg.Is>(s + => s.Model == Cases.CustomModelName && s.Input.Messages == Messages && s.Parameters == parameters)); } [Fact] @@ -62,8 +77,8 @@ public void MultimodalStream_UseEnum_Success() // Assert _ = client.Received().GetMultimodalGenerationStreamAsync( - Arg.Is>( - s => s.Model == "qwen-vl-plus" && s.Input.Messages == Messages && s.Parameters == parameters)); + Arg.Is>(s + => s.Model == "qwen-vl-plus" && s.Input.Messages == Messages && s.Parameters == parameters)); } [Fact] @@ -78,7 +93,7 @@ public void Multimodal_CustomModel_Success() // Assert _ = client.Received().GetMultimodalGenerationStreamAsync( - Arg.Is>( - s => s.Model == Cases.CustomModelName && s.Input.Messages == Messages && s.Parameters == parameters)); + Arg.Is>(s + => s.Model == Cases.CustomModelName && s.Input.Messages == Messages && s.Parameters == parameters)); } } diff --git a/test/Cnblogs.DashScope.Sdk.UnitTests/QWenTextGenerationApiTests.cs b/test/Cnblogs.DashScope.Sdk.UnitTests/QWenTextGenerationApiTests.cs index ddd6640..5fb1f6f 100644 --- a/test/Cnblogs.DashScope.Sdk.UnitTests/QWenTextGenerationApiTests.cs +++ b/test/Cnblogs.DashScope.Sdk.UnitTests/QWenTextGenerationApiTests.cs @@ -102,6 +102,20 @@ await client.Received().GetTextCompletionAsync( s => s.Input.Messages == Cases.TextMessages && s.Parameters == parameters && s.Model == "qwen-max-1201")); } + [Fact] + public async Task QWenChatCompletion_UseInvalidEnum_SuccessAsync() + { + // Arrange + var client = Substitute.For(); + var parameters = new TextGenerationParameters { EnableSearch = true, ResultFormat = ResultFormats.Message }; + + // Act + var act = async () => await client.GetQWenChatCompletionAsync((QWenLlm)(-1), Cases.TextMessages, parameters); + + // Assert + await Assert.ThrowsAsync(act); + } + [Fact] public async Task QWenChatCompletion_CustomModel_SuccessAsync() { diff --git a/test/Cnblogs.DashScope.Sdk.UnitTests/ServiceCollectionInjectorTests.cs b/test/Cnblogs.DashScope.Sdk.UnitTests/ServiceCollectionInjectorTests.cs index 02230cc..2d3ff04 100644 --- a/test/Cnblogs.DashScope.Sdk.UnitTests/ServiceCollectionInjectorTests.cs +++ b/test/Cnblogs.DashScope.Sdk.UnitTests/ServiceCollectionInjectorTests.cs @@ -1,4 +1,5 @@ using System.Net.Http.Headers; +using Cnblogs.DashScope.AspNetCore; using Cnblogs.DashScope.Core; using FluentAssertions; using Microsoft.Extensions.Configuration; @@ -20,11 +21,12 @@ public void Parameter_Normal_Inject() // Act services.AddDashScopeClient(ApiKey); var provider = services.BuildServiceProvider(); - var httpClient = provider.GetRequiredService().CreateClient(nameof(IDashScopeClient)); + var httpClient = provider.GetRequiredService() + .CreateClient(DashScopeAspNetCoreDefaults.DefaultHttpClientName); // Assert provider.GetRequiredService().Should().NotBeNull().And - .BeOfType(); + .BeOfType(); httpClient.Should().NotBeNull(); httpClient.DefaultRequestHeaders.Authorization.Should() .BeEquivalentTo(new AuthenticationHeaderValue("Bearer", ApiKey)); @@ -37,13 +39,14 @@ public void Parameter_HasProxy_Inject() var services = new ServiceCollection(); // Act - services.AddDashScopeClient(ApiKey, ProxyApi); + services.AddDashScopeClient(ApiKey, baseAddress: ProxyApi); var provider = services.BuildServiceProvider(); - var httpClient = provider.GetRequiredService().CreateClient(nameof(IDashScopeClient)); + var httpClient = provider.GetRequiredService() + .CreateClient(DashScopeAspNetCoreDefaults.DefaultHttpClientName); // Assert provider.GetRequiredService().Should().NotBeNull().And - .BeOfType(); + .BeOfType(); httpClient.Should().NotBeNull(); httpClient.DefaultRequestHeaders.Authorization.Should() .BeEquivalentTo(new AuthenticationHeaderValue("Bearer", ApiKey)); @@ -66,11 +69,12 @@ public void Configuration_Normal_Inject() // Act services.AddDashScopeClient(configuration); var provider = services.BuildServiceProvider(); - var httpClient = provider.GetRequiredService().CreateClient(nameof(IDashScopeClient)); + var httpClient = provider.GetRequiredService() + .CreateClient(DashScopeAspNetCoreDefaults.DefaultHttpClientName); // Assert provider.GetRequiredService().Should().NotBeNull().And - .BeOfType(); + .BeOfType(); httpClient.Should().NotBeNull(); httpClient.DefaultRequestHeaders.Authorization.Should() .BeEquivalentTo(new AuthenticationHeaderValue("Bearer", ApiKey)); @@ -93,11 +97,12 @@ public void Configuration_CustomSectionName_Inject() // Act services.AddDashScopeClient(configuration, "dashScopeCustom"); var provider = services.BuildServiceProvider(); - var httpClient = provider.GetRequiredService().CreateClient(nameof(IDashScopeClient)); + var httpClient = provider.GetRequiredService() + .CreateClient(DashScopeAspNetCoreDefaults.DefaultHttpClientName); // Assert provider.GetRequiredService().Should().NotBeNull().And - .BeOfType(); + .BeOfType(); httpClient.Should().NotBeNull(); httpClient.DefaultRequestHeaders.Authorization.Should() .BeEquivalentTo(new AuthenticationHeaderValue("Bearer", ApiKey)); @@ -111,14 +116,15 @@ public void Configuration_AddMultipleTime_Replace() var services = new ServiceCollection(); // Act - services.AddDashScopeClient(ApiKey, ProxyApi); - services.AddDashScopeClient(ApiKey, ProxyApi); + services.AddDashScopeClient(ApiKey, baseAddress: ProxyApi); + services.AddDashScopeClient(ApiKey, baseAddress: ProxyApi); var provider = services.BuildServiceProvider(); - var httpClient = provider.GetRequiredService().CreateClient(nameof(IDashScopeClient)); + var httpClient = provider.GetRequiredService() + .CreateClient(DashScopeAspNetCoreDefaults.DefaultHttpClientName); // Assert provider.GetRequiredService().Should().NotBeNull().And - .BeOfType(); + .BeOfType(); httpClient.Should().NotBeNull(); httpClient.DefaultRequestHeaders.Authorization.Should() .BeEquivalentTo(new AuthenticationHeaderValue("Bearer", ApiKey)); @@ -130,11 +136,7 @@ public void Configuration_NoApiKey_Throw() { // Arrange var services = new ServiceCollection(); - var config = new Dictionary - { - { "irrelevant", "irr" }, - { "dashScope:baseAddress", ProxyApi } - }; + var config = new Dictionary { { "irrelevant", "irr" }, { "dashScope:baseAddress", ProxyApi } }; var configuration = new ConfigurationBuilder().AddInMemoryCollection(config).Build(); // Act diff --git a/test/Cnblogs.DashScope.Sdk.UnitTests/SocketTestsCollection.cs b/test/Cnblogs.DashScope.Sdk.UnitTests/SocketTestsCollection.cs new file mode 100644 index 0000000..29e36f6 --- /dev/null +++ b/test/Cnblogs.DashScope.Sdk.UnitTests/SocketTestsCollection.cs @@ -0,0 +1,6 @@ +namespace Cnblogs.DashScope.Sdk.UnitTests; + +[CollectionDefinition(nameof(SocketTestsCollection), DisableParallelization = true)] +public class SocketTestsCollection : ICollectionFixture +{ +} diff --git a/test/Cnblogs.DashScope.Sdk.UnitTests/SocketTestsFixture.cs b/test/Cnblogs.DashScope.Sdk.UnitTests/SocketTestsFixture.cs new file mode 100644 index 0000000..d4917aa --- /dev/null +++ b/test/Cnblogs.DashScope.Sdk.UnitTests/SocketTestsFixture.cs @@ -0,0 +1,6 @@ +namespace Cnblogs.DashScope.Sdk.UnitTests; + +public class SocketTestsFixture +{ + // leaving blank on purpose +} diff --git a/test/Cnblogs.DashScope.Sdk.UnitTests/SpeechSynthesizerSerializationTests.cs b/test/Cnblogs.DashScope.Sdk.UnitTests/SpeechSynthesizerSerializationTests.cs new file mode 100644 index 0000000..ac20641 --- /dev/null +++ b/test/Cnblogs.DashScope.Sdk.UnitTests/SpeechSynthesizerSerializationTests.cs @@ -0,0 +1,206 @@ +using Cnblogs.DashScope.Tests.Shared.Utils; + +namespace Cnblogs.DashScope.Sdk.UnitTests; + +[Collection(nameof(SocketTestsCollection))] +public class SpeechSynthesizerSerializationTests +{ + [Fact] + public async Task RunTask_SpecifyTaskId_SuccessAsync() + { + // Arrange + var (client, _, server) = await Sut.GetSocketTestClientAsync(); + var snapshot = Snapshots.SpeechSynthesizer.RunTask; + var taskStartedEvent = Snapshots.SpeechSynthesizer.TaskStarted; + server.Playlist.Enqueue(async s => await s.WriteServerMessageAsync(taskStartedEvent.GetMessageJson())); + + // Act + using var session = await client.CreateSpeechSynthesizerSocketSessionAsync(snapshot.Message.Payload.Model!); + var taskId = await session.RunTaskAsync(snapshot.Message.Header.TaskId, snapshot.Message.Payload.Parameters!); + + // Assert + Assert.Equal(snapshot.Message.Header.TaskId, taskId); + Assert.True(Checkers.IsJsonEquivalent(server.ServerReceivedMessages.First(), snapshot.GetMessageJson())); + } + + [Fact] + public async Task RunTask_GenerateTaskId_SuccessAsync() + { + // Arrange + var (client, _, server) = await Sut.GetSocketTestClientAsync(); + var snapshot = Snapshots.SpeechSynthesizer.RunTask; + var taskStartedEvent = Snapshots.SpeechSynthesizer.TaskStarted; + server.Playlist.Enqueue(async s => await s.WriteServerMessageAsync(taskStartedEvent.GetMessageJson())); + + // Act + using var session = await client.CreateSpeechSynthesizerSocketSessionAsync(snapshot.Message.Payload.Model!); + var taskId = await session.RunTaskAsync(snapshot.Message.Payload.Parameters!); + + // Assert + var json = snapshot.GetMessageJson().Replace(snapshot.Message.Header.TaskId, taskId); + Assert.True(Checkers.IsJsonEquivalent(server.ServerReceivedMessages.First(), json)); + } + + [Fact] + public async Task ContinueTask_WithInput_SuccessAsync() + { + // Arrange + var (client, _, server) = await Sut.GetSocketTestClientAsync(); + var runTask = Snapshots.SpeechSynthesizer.RunTask; + var continueTask = Snapshots.SpeechSynthesizer.ContinueTask; + var taskStartedEvent = Snapshots.SpeechSynthesizer.TaskStarted; + server.Playlist.Enqueue(async s => await s.WriteServerMessageAsync(taskStartedEvent.GetMessageJson())); + + // Act + using var session = await client.CreateSpeechSynthesizerSocketSessionAsync(runTask.Message.Payload.Model!); + await session.RunTaskAsync(runTask.Message.Header.TaskId, runTask.Message.Payload.Parameters!); + await session.ContinueTaskAsync(continueTask.Message.Header.TaskId, continueTask.Message.Payload.Input.Text!); + + // Assert + Assert.True(Checkers.IsJsonEquivalent(server.ServerReceivedMessages.Last(), continueTask.GetMessageJson())); + } + + [Fact] + public async Task FinishTask_NoPayload_SuccessAsync() + { + // Arrange + var (client, _, server) = await Sut.GetSocketTestClientAsync(); + var runTask = Snapshots.SpeechSynthesizer.RunTask; + var continueTask = Snapshots.SpeechSynthesizer.ContinueTask; + var finishTask = Snapshots.SpeechSynthesizer.FinishTask; + var taskStartedEvent = Snapshots.SpeechSynthesizer.TaskStarted; + server.Playlist.Enqueue(async s => await s.WriteServerMessageAsync(taskStartedEvent.GetMessageJson())); + + // Act + using var session = await client.CreateSpeechSynthesizerSocketSessionAsync(runTask.Message.Payload.Model!); + await session.RunTaskAsync(runTask.Message.Header.TaskId, runTask.Message.Payload.Parameters!); + await session.ContinueTaskAsync(continueTask.Message.Header.TaskId, continueTask.Message.Payload.Input.Text!); + await session.FinishTaskAsync(finishTask.Message.Header.TaskId); + + // Assert + Assert.True(Checkers.IsJsonEquivalent(server.ServerReceivedMessages.Last(), finishTask.GetMessageJson())); + } + + [Fact] + public async Task ResultGenerated_WithBinary_SuccessAsync() + { + // Arrange + var (client, _, server) = await Sut.GetSocketTestClientAsync(); + var runTask = Snapshots.SpeechSynthesizer.RunTask; + var finishTask = Snapshots.SpeechSynthesizer.FinishTask; + var resultGenerated = Snapshots.SpeechSynthesizer.ResultGenerated; + var ttsBinary = Snapshots.SpeechSynthesizer.AudioTts; + var taskStartedEvent = Snapshots.SpeechSynthesizer.TaskStarted; + server.Playlist.Enqueue(async s => await s.WriteServerMessageAsync(taskStartedEvent.GetMessageJson())); + server.Playlist.Enqueue(async s => + { + await s.WriteServerMessageAsync(resultGenerated.GetMessageJson()); + await s.WriteServerMessageAsync(ttsBinary); + await s.WriteServerCloseAsync(); + }); + + // Act + using var session = await client.CreateSpeechSynthesizerSocketSessionAsync(runTask.Message.Payload.Model!); + await session.RunTaskAsync(runTask.Message.Header.TaskId, runTask.Message.Payload.Parameters!); + await session.FinishTaskAsync(finishTask.Message.Header.TaskId); + var jsonEvents = await session.GetMessagesAsync().ToListAsync(); + var binaryContent = await session.GetAudioAsync().ToArrayAsync(); + + // Assert + Assert.Equivalent(ttsBinary, binaryContent); + Assert.Equal(2, jsonEvents.Count); // task-started, result-generated + Assert.Equivalent(resultGenerated.Message, jsonEvents.Last()); + } + + [Fact] + public async Task TaskFinished_ServerClose_SuccessAsync() + { + // Arrange + var (client, _, server) = await Sut.GetSocketTestClientAsync(); + var runTask = Snapshots.SpeechSynthesizer.RunTask; + var finishTask = Snapshots.SpeechSynthesizer.FinishTask; + var resultGenerated = Snapshots.SpeechSynthesizer.ResultGenerated; + var taskFinished = Snapshots.SpeechSynthesizer.TaskFinished; + var ttsBinary = Snapshots.SpeechSynthesizer.AudioTts; + var taskStartedEvent = Snapshots.SpeechSynthesizer.TaskStarted; + server.Playlist.Enqueue(async s => await s.WriteServerMessageAsync(taskStartedEvent.GetMessageJson())); + server.Playlist.Enqueue(async s => + { + await s.WriteServerMessageAsync(resultGenerated.GetMessageJson()); + await s.WriteServerMessageAsync(ttsBinary); + await s.WriteServerMessageAsync(taskFinished.GetMessageJson()); + await s.WriteServerCloseAsync(); + }); + + // Act + using var session = await client.CreateSpeechSynthesizerSocketSessionAsync(runTask.Message.Payload.Model!); + await session.RunTaskAsync(runTask.Message.Header.TaskId, runTask.Message.Payload.Parameters!); + await session.FinishTaskAsync(finishTask.Message.Header.TaskId); + var jsonEvents = await session.GetMessagesAsync().ToListAsync(); + var binaryContent = await session.GetAudioAsync().ToArrayAsync(); + + // Assert + Assert.Equivalent(ttsBinary, binaryContent); + Assert.Equal(3, jsonEvents.Count); // task-started, result-generated, task-finished + Assert.Equivalent(taskFinished.Message, jsonEvents.Last()); + } + + [Fact] + public async Task TaskFailed_ServerClose_SuccessAsync() + { + // Arrange + var (client, _, server) = await Sut.GetSocketTestClientAsync(); + var runTask = Snapshots.SpeechSynthesizer.RunTask; + var finishTask = Snapshots.SpeechSynthesizer.FinishTask; + var taskFailed = Snapshots.SpeechSynthesizer.TaskFailed; + var taskStarted = Snapshots.SpeechSynthesizer.TaskStarted; + server.Playlist.Enqueue(async s => await s.WriteServerMessageAsync(taskStarted.GetMessageJson())); + server.Playlist.Enqueue(async s => + { + await s.WriteServerMessageAsync(taskFailed.GetMessageJson()); + await s.WriteServerCloseAsync(); + }); + + // Act + using var session = await client.CreateSpeechSynthesizerSocketSessionAsync(runTask.Message.Payload.Model!); + await session.RunTaskAsync(runTask.Message.Header.TaskId, runTask.Message.Payload.Parameters!); + await session.FinishTaskAsync(finishTask.Message.Header.TaskId); + var jsonEvents = await session.GetMessagesAsync().ToListAsync(); + var binaryContent = await session.GetAudioAsync().ToArrayAsync(); + + // Assert + Assert.Empty(binaryContent); + Assert.Equal(2, jsonEvents.Count); // task-started, task-failed + Assert.Equivalent(taskFailed.Message, jsonEvents.Last()); + } + + [Fact] + public async Task Dispose_DisposedByUsings_ReturnSocketAsync() + { + // Arrange + var (client, _, server) = await Sut.GetSocketTestClientAsync(); + var runTask = Snapshots.SpeechSynthesizer.RunTask; + var finishTask = Snapshots.SpeechSynthesizer.FinishTask; + var resultGenerated = Snapshots.SpeechSynthesizer.ResultGenerated; + var taskFinished = Snapshots.SpeechSynthesizer.TaskFinished; + var ttsBinary = Snapshots.SpeechSynthesizer.AudioTts; + var taskStartedEvent = Snapshots.SpeechSynthesizer.TaskStarted; + server.Playlist.Enqueue(async s => await s.WriteServerMessageAsync(taskStartedEvent.GetMessageJson())); + server.Playlist.Enqueue(async s => + { + await s.WriteServerMessageAsync(resultGenerated.GetMessageJson()); + await s.WriteServerMessageAsync(ttsBinary); + await s.WriteServerMessageAsync(taskFinished.GetMessageJson()); + }); + + // Act + using (var session = await client.CreateSpeechSynthesizerSocketSessionAsync(runTask.Message.Payload.Model!)) + { + await session.RunTaskAsync(runTask.Message.Header.TaskId, runTask.Message.Payload.Parameters!); + await session.FinishTaskAsync(finishTask.Message.Header.TaskId); + } + + // Assert + Assert.False(server.DisposeCalled); + } +} diff --git a/test/Cnblogs.DashScope.Sdk.UnitTests/TextEmbeddingApiTests.cs b/test/Cnblogs.DashScope.Sdk.UnitTests/TextEmbeddingApiTests.cs index 5ad66d3..d82d43a 100644 --- a/test/Cnblogs.DashScope.Sdk.UnitTests/TextEmbeddingApiTests.cs +++ b/test/Cnblogs.DashScope.Sdk.UnitTests/TextEmbeddingApiTests.cs @@ -20,8 +20,23 @@ public async Task GetEmbeddings_UseEnum_SuccessAsync() // Assert await client.Received().GetEmbeddingsAsync( - Arg.Is>( - s => s.Input.Texts == texts && s.Model == "text-embedding-v2" && s.Parameters == parameters)); + Arg.Is>(s + => s.Input.Texts == texts && s.Model == "text-embedding-v2" && s.Parameters == parameters)); + } + + [Fact] + public async Task GetEmbeddings_UseInvalidEnum_SuccessAsync() + { + // Arrange + var client = Substitute.For(); + var texts = new[] { "hello" }; + var parameters = new TextEmbeddingParameters { TextType = TextTypes.Query }; + + // Act + var act = async () => await client.GetTextEmbeddingsAsync((TextEmbeddingModel)(-1), texts, parameters); + + // Assert + await Assert.ThrowsAsync(act); } [Fact] @@ -37,7 +52,7 @@ public async Task GetEmbeddings_CustomModel_SuccessAsync() // Assert await client.Received().GetEmbeddingsAsync( - Arg.Is>( - s => s.Input.Texts == texts && s.Model == Cases.CustomModelName && s.Parameters == parameters)); + Arg.Is>(s + => s.Input.Texts == texts && s.Model == Cases.CustomModelName && s.Parameters == parameters)); } } diff --git a/test/Cnblogs.DashScope.Sdk.UnitTests/TextGenerationSerializationTests.cs b/test/Cnblogs.DashScope.Sdk.UnitTests/TextGenerationSerializationTests.cs index 9a9406e..edc0bf4 100644 --- a/test/Cnblogs.DashScope.Sdk.UnitTests/TextGenerationSerializationTests.cs +++ b/test/Cnblogs.DashScope.Sdk.UnitTests/TextGenerationSerializationTests.cs @@ -155,7 +155,10 @@ public async Task ConversationCompletion_MessageFormatSse_SuccessAsync( Snapshots.TextGeneration.MessageFormat.SingleMessage, Snapshots.TextGeneration.MessageFormat.SingleMessageReasoning, Snapshots.TextGeneration.MessageFormat.SingleMessageWithTools, - Snapshots.TextGeneration.MessageFormat.SingleMessageJson); + Snapshots.TextGeneration.MessageFormat.SingleMessageJson, + Snapshots.TextGeneration.MessageFormat.SingleMessageLogprobs, + Snapshots.TextGeneration.MessageFormat.SingleMessageTranslation, + Snapshots.TextGeneration.MessageFormat.SingleMessageWebSearch); public static readonly TheoryData, ModelResponse>> SingleGenerationMessageSseFormatData = new( diff --git a/test/Cnblogs.DashScope.Sdk.UnitTests/WanxApiTests.cs b/test/Cnblogs.DashScope.Sdk.UnitTests/WanxApiTests.cs index ff05d7c..ad0dad0 100644 --- a/test/Cnblogs.DashScope.Sdk.UnitTests/WanxApiTests.cs +++ b/test/Cnblogs.DashScope.Sdk.UnitTests/WanxApiTests.cs @@ -42,6 +42,27 @@ public async Task WanxImageSynthesis_UseEnum_SuccessAsync() && s.Parameters == Parameters)); } + [Fact] + public async Task WanxImageSynthesis_UseInvalidEnum_SuccessAsync() + { + // Arrange + var client = Substitute.For(); + client.Configure().CreateImageSynthesisTaskAsync( + Arg.Any>(), + Arg.Any()) + .Returns(Snapshots.ImageSynthesis.CreateTask.ResponseModel); + + // Act + var act = async () => await client.CreateWanxImageSynthesisTaskAsync( + (WanxModel)(-1), + Cases.Prompt, + Cases.PromptAlter, + Parameters); + + // Assert + await Assert.ThrowsAsync(act); + } + [Fact] public async Task WanxImageSynthesis_CustomModel_SuccessAsync() { @@ -104,6 +125,25 @@ public async Task WanxImageGeneration_UseEnum_SuccessAsync() && s.Input.StyleIndex == 3)); } + [Fact] + public async Task WanxImageGeneration_UseInvalidEnum_SuccessAsync() + { + // Arrange + var client = Substitute.For(); + client.Configure().CreateImageGenerationTaskAsync( + Arg.Any>(), + Arg.Any()) + .Returns(Snapshots.ImageGeneration.CreateTaskNoSse.ResponseModel); + + // Act + var act = async () => await client.CreateWanxImageGenerationTaskAsync( + (WanxStyleRepaintModel)(-1), + new ImageGenerationInput { ImageUrl = Cases.ImageUrl, StyleIndex = 3 }); + + // Assert + await Assert.ThrowsAsync(act); + } + [Fact] public async Task WanxImageGeneration_CustomModel_SuccessAsync() { @@ -162,6 +202,25 @@ public async Task WanxBackgroundImageGeneration_UseEnum_SuccessAsync() && s.Input.BaseImageUrl == Cases.ImageUrl)); } + [Fact] + public async Task WanxBackgroundImageGeneration_UseInvalidEnum_SuccessAsync() + { + // Arrange + var client = Substitute.For(); + client.Configure().CreateBackgroundGenerationTaskAsync( + Arg.Any>(), + Arg.Any()) + .Returns(Snapshots.BackgroundGeneration.CreateTaskNoSse.ResponseModel); + + // Act + var act = async () => await client.CreateWanxBackgroundGenerationTaskAsync( + (WanxBackgroundGenerationModel)(-1), + new BackgroundGenerationInput { BaseImageUrl = Cases.ImageUrl }); + + // Assert + await Assert.ThrowsAsync(act); + } + [Fact] public async Task WanxBackgroundImageGeneration_CustomModel_SuccessAsync() { diff --git a/test/Cnblogs.DashScope.Tests.Shared/Assembly.cs b/test/Cnblogs.DashScope.Tests.Shared/Assembly.cs new file mode 100644 index 0000000..7c8a352 --- /dev/null +++ b/test/Cnblogs.DashScope.Tests.Shared/Assembly.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Cnblogs.DashScope.Sdk.UnitTests")] +[assembly: InternalsVisibleTo("Cnblogs.DashScope.AI.UnitTests")] diff --git a/test/Cnblogs.DashScope.Tests.Shared/Cnblogs.DashScope.Tests.Shared.csproj b/test/Cnblogs.DashScope.Tests.Shared/Cnblogs.DashScope.Tests.Shared.csproj index 6c9b008..ab1aa6b 100644 --- a/test/Cnblogs.DashScope.Tests.Shared/Cnblogs.DashScope.Tests.Shared.csproj +++ b/test/Cnblogs.DashScope.Tests.Shared/Cnblogs.DashScope.Tests.Shared.csproj @@ -8,9 +8,9 @@ - + - + diff --git a/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/single-generation-message-logprobs-nosse.request.body.json b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/single-generation-message-logprobs-nosse.request.body.json new file mode 100644 index 0000000..b47b634 --- /dev/null +++ b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/single-generation-message-logprobs-nosse.request.body.json @@ -0,0 +1,22 @@ +{ + "model": "qwen-max", + "input": { + "messages": [ + { + "role": "user", + "content": "请问 1+1 是多少?请直接输出结果" + } + ] + }, + "parameters": { + "result_format": "message", + "seed": 1234, + "max_tokens": 1500, + "top_p": 0.8, + "top_k": 100, + "repetition_penalty": 1.1, + "temperature": 0.85, + "logprobs": true, + "top_logprobs": 2 + } +} diff --git a/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/single-generation-message-logprobs-nosse.request.header.txt b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/single-generation-message-logprobs-nosse.request.header.txt new file mode 100644 index 0000000..0c616aa --- /dev/null +++ b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/single-generation-message-logprobs-nosse.request.header.txt @@ -0,0 +1,8 @@ +POST /api/v1/services/aigc/text-generation/generation HTTP/1.1 +Content-Type: application/json +Accept: */* +Cache-Control: no-cache +Host: dashscope.aliyuncs.com +Accept-Encoding: gzip, deflate, br +Connection: keep-alive +Content-Length: 592 diff --git a/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/single-generation-message-logprobs-nosse.response.body.txt b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/single-generation-message-logprobs-nosse.response.body.txt new file mode 100644 index 0000000..57dfbe9 --- /dev/null +++ b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/single-generation-message-logprobs-nosse.response.body.txt @@ -0,0 +1 @@ +{"output":{"choices":[{"finish_reason":"stop","message":{"role":"assistant","content":"2"},"logprobs":{"content":[{"top_logprobs":[{"logprob":0.0,"bytes":[50],"token":"2"}],"logprob":0.0,"bytes":[50],"token":"2"}]}}]},"usage":{"total_tokens":21,"output_tokens":1,"input_tokens":20,"prompt_tokens_details":{"cached_tokens":0}},"request_id":"1d881da5-0028-9f20-8e7f-6bc7ae891c54"} diff --git a/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/single-generation-message-logprobs-nosse.response.header.txt b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/single-generation-message-logprobs-nosse.response.header.txt new file mode 100644 index 0000000..2799d49 --- /dev/null +++ b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/single-generation-message-logprobs-nosse.response.header.txt @@ -0,0 +1,15 @@ +HTTP/1.1 200 OK +vary: Origin,Access-Control-Request-Method,Access-Control-Request-Headers, Accept-Encoding +content-type: application/json +x-request-id: 1d881da5-0028-9f20-8e7f-6bc7ae891c54 +x-dashscope-call-gateway: true +x-dashscope-finished: true +x-dashscope-timeout: 298 +req-cost-time: 261 +req-arrive-time: 1751901115333 +resp-start-time: 1751901115594 +x-envoy-upstream-service-time: 252 +content-encoding: gzip +date: Mon, 07 Jul 2025 15:11:55 GMT +server: istio-envoy +transfer-encoding: chunked diff --git a/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/single-generation-message-reasoning-sse.request.body.json b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/single-generation-message-reasoning-sse.request.body.json index cfe607c..d44fefe 100644 --- a/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/single-generation-message-reasoning-sse.request.body.json +++ b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/single-generation-message-reasoning-sse.request.body.json @@ -9,8 +9,9 @@ ] }, "parameters": { - "incremental_output": true, "result_format": "message", - "enable_thinking": true + "incremental_output": true, + "enable_thinking": true, + "thinking_budget": 10 } } diff --git a/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/single-generation-message-reasoning-sse.response.body.txt b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/single-generation-message-reasoning-sse.response.body.txt index cc433c4..5e0bcd0 100644 --- a/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/single-generation-message-reasoning-sse.response.body.txt +++ b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/single-generation-message-reasoning-sse.response.body.txt @@ -1,490 +1,65 @@ + id:1 event:result :HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"嗯","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":19,"output_tokens":3,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":1}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - +data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"嗯","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":19,"output_tokens":3,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":1}},"request_id":"ab9f3446-9bbf-963e-9754-2d6543343d7e"} id:2 event:result :HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":",","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":20,"output_tokens":4,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":2}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - +data:{"output":{"choices":[{"message":{"content":"","reasoning_content":",","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":20,"output_tokens":4,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":2}},"request_id":"ab9f3446-9bbf-963e-9754-2d6543343d7e"} id:3 event:result :HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"用户","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":21,"output_tokens":5,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":3}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - +data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"用户","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":21,"output_tokens":5,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":3}},"request_id":"ab9f3446-9bbf-963e-9754-2d6543343d7e"} id:4 event:result :HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"问的是“1","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":25,"output_tokens":9,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":7}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - +data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"问的是“1","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":25,"output_tokens":9,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":7}},"request_id":"ab9f3446-9bbf-963e-9754-2d6543343d7e"} id:5 event:result :HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"+1是多少?”","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":29,"output_tokens":13,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":11}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - +data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"+1是多少","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":31,"output_tokens":15,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":10}},"request_id":"ab9f3446-9bbf-963e-9754-2d6543343d7e"} id:6 event:result :HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"这个问题看起来很简单,","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":33,"output_tokens":17,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":15}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - +data:{"output":{"choices":[{"message":{"content":"1+1","reasoning_content":"","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":35,"output_tokens":19,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":10}},"request_id":"ab9f3446-9bbf-963e-9754-2d6543343d7e"} id:7 event:result :HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"但可能需要考虑","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":37,"output_tokens":21,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":19}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - +data:{"output":{"choices":[{"message":{"content":" 等于 **","reasoning_content":"","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":39,"output_tokens":23,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":10}},"request_id":"ab9f3446-9bbf-963e-9754-2d6543343d7e"} id:8 event:result :HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"不同的上下文。","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":41,"output_tokens":25,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":23}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - +data:{"output":{"choices":[{"message":{"content":"2**。这是","reasoning_content":"","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":43,"output_tokens":27,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":10}},"request_id":"ab9f3446-9bbf-963e-9754-2d6543343d7e"} id:9 event:result :HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"首先,在数学中","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":45,"output_tokens":29,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":27}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - +data:{"output":{"choices":[{"message":{"content":"数学中最基本的","reasoning_content":"","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":47,"output_tokens":31,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":10}},"request_id":"ab9f3446-9bbf-963e-9754-2d6543343d7e"} id:10 event:result :HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":",1+1","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":49,"output_tokens":33,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":31}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - +data:{"output":{"choices":[{"message":{"content":"加法运算之一","reasoning_content":"","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":51,"output_tokens":35,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":10}},"request_id":"ab9f3446-9bbf-963e-9754-2d6543343d7e"} id:11 event:result :HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"显然等于2,","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":53,"output_tokens":37,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":35}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - +data:{"output":{"choices":[{"message":{"content":"。\n\n如果你有其他","reasoning_content":"","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":55,"output_tokens":39,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":10}},"request_id":"ab9f3446-9bbf-963e-9754-2d6543343d7e"} id:12 event:result :HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"这是基本的算","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":57,"output_tokens":41,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":39}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - +data:{"output":{"choices":[{"message":{"content":"关于数学、科学","reasoning_content":"","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":59,"output_tokens":43,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":10}},"request_id":"ab9f3446-9bbf-963e-9754-2d6543343d7e"} id:13 event:result :HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"术。不过有时候","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":61,"output_tokens":45,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":43}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - +data:{"output":{"choices":[{"message":{"content":"或任何领域的问题","reasoning_content":"","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":63,"output_tokens":47,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":10}},"request_id":"ab9f3446-9bbf-963e-9754-2d6543343d7e"} id:14 event:result :HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"可能会有其他解释","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":65,"output_tokens":49,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":47}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - +data:{"output":{"choices":[{"message":{"content":",欢迎继续提问","reasoning_content":"","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":67,"output_tokens":51,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":10}},"request_id":"ab9f3446-9bbf-963e-9754-2d6543343d7e"} id:15 event:result :HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":",比如在编程","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":69,"output_tokens":53,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":51}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - +data:{"output":{"choices":[{"message":{"content":"!😊","reasoning_content":"","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":69,"output_tokens":53,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":10}},"request_id":"ab9f3446-9bbf-963e-9754-2d6543343d7e"} id:16 event:result :HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"里,字符串拼","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":73,"output_tokens":57,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":55}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:17 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"接的话结果可能是","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":77,"output_tokens":61,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":59}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:18 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"“11”。","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":81,"output_tokens":65,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":63}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:19 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"或者在某些比喻","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":85,"output_tokens":69,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":67}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:20 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"的情况下,比如两个人","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":89,"output_tokens":73,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":71}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:21 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"合作,可能会有不同的","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":93,"output_tokens":77,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":75}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:22 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"解释。不过用户","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":97,"output_tokens":81,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":79}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:23 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"没有给出具体的场景","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":101,"output_tokens":85,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":83}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:24 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":",所以应该默认","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":105,"output_tokens":89,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":87}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:25 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"是数学问题。","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":109,"output_tokens":93,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":91}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:26 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"\n\n接下来,我需要","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":113,"output_tokens":97,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":95}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:27 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"确认用户的需求。","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":117,"output_tokens":101,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":99}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:28 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"可能的情况是:","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":121,"output_tokens":105,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":103}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:29 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"他们真的在问","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":125,"output_tokens":109,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":107}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:30 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"数学问题,或者","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":129,"output_tokens":113,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":111}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:31 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"测试我的回答是否","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":133,"output_tokens":117,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":115}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:32 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"正确,或者想","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":137,"output_tokens":121,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":119}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:33 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"看看我会不会考虑","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":141,"output_tokens":125,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":123}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:34 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"其他可能性。比如","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":145,"output_tokens":129,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":127}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:35 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":",有些时候人们","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":149,"output_tokens":133,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":131}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:36 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"会开玩笑说1","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":153,"output_tokens":137,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":135}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:37 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"+1等于3","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":157,"output_tokens":141,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":139}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:38 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":",指的是家庭组成","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":161,"output_tokens":145,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":143}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:39 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":",但这种情况可能","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":165,"output_tokens":149,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":147}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:40 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"需要更多上下文","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":169,"output_tokens":153,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":151}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:41 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"。\n\n另外,用户","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":173,"output_tokens":157,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":155}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:42 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"可能有不同的教育背景","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":177,"output_tokens":161,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":159}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:43 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":",比如小孩子刚开始","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":181,"output_tokens":165,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":163}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:44 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"学数学,可能","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":185,"output_tokens":169,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":167}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:45 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"需要更详细的解释","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":189,"output_tokens":173,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":171}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:46 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":",但问题本身","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":193,"output_tokens":177,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":175}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:47 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"太基础,可能","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":197,"output_tokens":181,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":179}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:48 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"不需要深入。或者","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":201,"output_tokens":185,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":183}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:49 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"用户可能是在检查","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":205,"output_tokens":189,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":187}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:50 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"我的基本功能是否","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":209,"output_tokens":193,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":191}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:51 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"正常,所以回答","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":213,"output_tokens":197,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":195}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:52 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"要简洁准确。","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":217,"output_tokens":201,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":199}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:53 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"\n\n还要考虑是否存在其他","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":221,"output_tokens":205,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":203}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:54 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"可能的答案,比如","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":225,"output_tokens":209,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":207}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:55 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"在二进制","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":229,"output_tokens":213,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":211}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:56 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"中,1+","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":233,"output_tokens":217,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":215}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:57 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"1是10","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":237,"output_tokens":221,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":219}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:58 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":",但通常在","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":241,"output_tokens":225,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":223}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:59 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"十进制环境下","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":245,"output_tokens":229,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":227}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:60 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"还是回答2。","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":249,"output_tokens":233,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":231}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:61 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"不过如果用户有","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":253,"output_tokens":237,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":235}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:62 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"特定领域的需求,","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":257,"output_tokens":241,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":239}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:63 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"可能需要进一步询问","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":261,"output_tokens":245,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":243}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:64 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"。但根据问题","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":265,"output_tokens":249,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":247}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:65 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"本身,没有提示","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":269,"output_tokens":253,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":251}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:66 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"其他进制或","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":273,"output_tokens":257,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":255}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:67 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"特殊情境,所以","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":277,"output_tokens":261,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":259}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:68 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"应该以常规回答","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":281,"output_tokens":265,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":263}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:69 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"为主。\n\n总结下来","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":285,"output_tokens":269,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":267}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:70 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":",最稳妥的回答","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":289,"output_tokens":273,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":271}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:71 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"是先给出数学","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":293,"output_tokens":277,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":275}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:72 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"上的答案2,","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":297,"output_tokens":281,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":279}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:73 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"然后简要提到","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":301,"output_tokens":285,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":283}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:74 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"可能的其他情况","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":305,"output_tokens":289,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":287}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:75 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":",但说明通常","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":309,"output_tokens":293,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":291}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:76 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"默认是指数学","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":313,"output_tokens":297,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":295}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:77 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"加法。这样","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":317,"output_tokens":301,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":299}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:78 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"既准确又全面","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":321,"output_tokens":305,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":303}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:79 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":",避免误解。","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":325,"output_tokens":309,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":307}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:80 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"在数学","reasoning_content":"","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":329,"output_tokens":313,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":306}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:81 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"中,**1","reasoning_content":"","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":333,"output_tokens":317,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":306}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:82 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":" + 1 =","reasoning_content":"","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":337,"output_tokens":321,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":306}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:83 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":" 2**,","reasoning_content":"","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":341,"output_tokens":325,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":306}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:84 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"这是基本的算","reasoning_content":"","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":345,"output_tokens":329,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":306}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:85 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"术加法运算","reasoning_content":"","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":349,"output_tokens":333,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":306}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:86 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"。 \n如果是在","reasoning_content":"","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":353,"output_tokens":337,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":306}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:87 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"其他特殊语境","reasoning_content":"","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":357,"output_tokens":341,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":306}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:88 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"下(例如编程","reasoning_content":"","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":361,"output_tokens":345,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":306}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:89 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"中的字符串拼接","reasoning_content":"","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":365,"output_tokens":349,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":306}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:90 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"、二进制","reasoning_content":"","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":369,"output_tokens":353,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":306}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:91 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"计算,或比喻","reasoning_content":"","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":373,"output_tokens":357,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":306}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:92 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"性表达),答案","reasoning_content":"","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":377,"output_tokens":361,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":306}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:93 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"可能不同,但","reasoning_content":"","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":381,"output_tokens":365,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":306}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:94 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"通常默认情况下,","reasoning_content":"","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":385,"output_tokens":369,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":306}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:95 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"1+1的结果","reasoning_content":"","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":389,"output_tokens":373,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":306}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:96 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"是**2**","reasoning_content":"","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":393,"output_tokens":377,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":306}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:97 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"。","reasoning_content":"","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":394,"output_tokens":378,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":306}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - -id:98 -event:result -:HTTP_STATUS/200 -data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"","role":"assistant"},"finish_reason":"stop"}]},"usage":{"total_tokens":394,"output_tokens":378,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":306}},"request_id":"d21851a2-675b-97a3-9132-2935c31d6ee3"} - +data:{"output":{"choices":[{"message":{"content":"","reasoning_content":"","role":"assistant"},"finish_reason":"stop"}]},"usage":{"total_tokens":69,"output_tokens":53,"input_tokens":16,"output_tokens_details":{"reasoning_tokens":10}},"request_id":"ab9f3446-9bbf-963e-9754-2d6543343d7e"} diff --git a/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/single-generation-message-search-nosse.request.body.json b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/single-generation-message-search-nosse.request.body.json new file mode 100644 index 0000000..7a30117 --- /dev/null +++ b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/single-generation-message-search-nosse.request.body.json @@ -0,0 +1,22 @@ +{ + "model": "qwen-max", + "input": { + "messages": [ + { + "role": "user", + "content": "总结博客园 dudu 的最新博客" + } + ] + }, + "parameters": { + "result_format": "message", + "enable_search": true, + "search_options": { + "enable_source": true, + "enable_citation": true, + "citation_format": "[ref_]", + "forced_search": true, + "search_strategy": "standard" + } + } +} diff --git a/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/single-generation-message-search-nosse.request.header.txt b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/single-generation-message-search-nosse.request.header.txt new file mode 100644 index 0000000..8f77480 --- /dev/null +++ b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/single-generation-message-search-nosse.request.header.txt @@ -0,0 +1,8 @@ +POST /api/v1/services/aigc/text-generation/generation HTTP/1.1 +Content-Type: application/json +Accept: */* +Cache-Control: no-cache +Host: dashscope.aliyuncs.com +Accept-Encoding: gzip, deflate, br +Connection: keep-alive +Content-Length: 592 diff --git a/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/single-generation-message-search-nosse.response.body.txt b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/single-generation-message-search-nosse.response.body.txt new file mode 100644 index 0000000..ed84299 --- /dev/null +++ b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/single-generation-message-search-nosse.response.body.txt @@ -0,0 +1 @@ +{"output":{"search_info":{"search_results":[{"site_name":"CSDN - 专业开发者社区","icon":"https://img.alicdn.com/imgextra/i3/O1CN01QA3ndK1maJQ8rZTo1_!!6000000004970-55-tps-32-32.svg","index":1,"title":"我与博客园的20年转载","url":"https://blog.csdn.net/weixin_40884228/article/details/148485212"},{"site_name":"博客园","icon":"https://img.alicdn.com/imgextra/i2/O1CN01FzHbv01o253A3z2Gd_!!6000000005166-55-tps-32-32.svg","index":2,"title":"dudu - 博客园","url":"https://www.cnblogs.com/dudu"},{"site_name":"博客园","icon":"https://img.alicdn.com/imgextra/i2/O1CN01FzHbv01o253A3z2Gd_!!6000000005166-55-tps-32-32.svg","index":3,"title":"dudu - 博客园","url":"https://www.cnblogs.com/dudu?page=36"},{"site_name":"阿里云官方网站","icon":"https://img.alicdn.com/imgextra/i3/O1CN015NhUWq1Z1sdj3359l_!!6000000003135-55-tps-32-32.svg","index":4,"title":"玩转博客园的心路总结 - 阿里云开发者社区","url":"https://developer.aliyun.com/article/331235"},{"site_name":"CSDN - 专业开发者社区","icon":"https://img.alicdn.com/imgextra/i3/O1CN01QA3ndK1maJQ8rZTo1_!!6000000004970-55-tps-32-32.svg","index":5,"title":"为.NET程序员打工的站长——博客园dudu 原创","url":"https://blog.csdn.net/Microsoft_MVP/article/details/2416055"}]},"choices":[{"finish_reason":"stop","message":{"role":"assistant","content":"截至2025年6月7日,博客园的dudu站长发布的内容包括了技术分享和个人经历总结。以下是对dudu最近博客内容的一个概括:\n\n1. 代码重构经验分享:dudu在一篇博客中分享了他在博客园后台开发过程中遇到的一次代码重构经历。这次重构涉及到两个列表的合并(union),他需要实现一个自定义的`EqualityComparer`,基于列表元素的`Id`字段来进行比较,而不是默认的对象引用比较。这表明dudu在持续关注和改进博客园的技术架构,以确保其高效和可维护性。[ref_2]\n\n2. 开源工具介绍:另一篇博客介绍了名为NBearMapping的开源对象映射工具,该工具可用于不同类型的对象、DataRow以及DataReader之间的数据映射。dudu提到这个工具对于开发者来说非常有用,因为它可以简化数据层与业务逻辑层之间的交互。[ref_3]\n\n此外,还有关于个人与博客园共同成长的感想,提到了在过去20年间,无论是个人还是博客园本身都经历了巨大的变化。dudu也提到了自己正面临一些个人生活中的挑战,并表达了对博客园社区理解和支持的感激之情。[ref_1]\n\n这些博客不仅展示了dudu作为技术人员的专业知识和技术分享的热情,还反映了他对博客园这个平台的深厚感情和个人投入。如果您需要更详细的博客内容或有其他问题,请告知我以便提供进一步的帮助。"}}]},"usage":{"plugins":{"search":{"count":1}},"total_tokens":800,"output_tokens":304,"input_tokens":496,"prompt_tokens_details":{"cached_tokens":0}},"request_id":"80753a20-2750-9ab6-bc2a-1b851ef43efc"} diff --git a/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/single-generation-message-search-nosse.response.header.txt b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/single-generation-message-search-nosse.response.header.txt new file mode 100644 index 0000000..8349277 --- /dev/null +++ b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/single-generation-message-search-nosse.response.header.txt @@ -0,0 +1,15 @@ +HTTP/1.1 200 OK +vary: Origin,Access-Control-Request-Method,Access-Control-Request-Headers, Accept-Encoding +content-type: application/json +x-request-id: 405d57ba-6cfc-9519-977f-0f519f712364 +x-dashscope-call-gateway: true +x-dashscope-finished: true +x-dashscope-timeout: 298 +req-cost-time: 810 +req-arrive-time: 1751899675324 +resp-start-time: 1751899676135 +x-envoy-upstream-service-time: 802 +content-encoding: gzip +date: Mon, 07 Jul 2025 14:47:55 GMT +server: istio-envoy +transfer-encoding: chunked diff --git a/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/single-generation-message-translation-nosse.request.body.json b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/single-generation-message-translation-nosse.request.body.json new file mode 100644 index 0000000..f4ea34c --- /dev/null +++ b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/single-generation-message-translation-nosse.request.body.json @@ -0,0 +1,32 @@ +{ + "model": "qwen-mt-plus", + "input": { + "messages": [ + { + "role": "user", + "content": "博客园的理念是代码改变世界" + } + ] + }, + "parameters": { + "result_format": "message", + "incremental_output": false, + "translation_options": { + "source_lang": "Chinese", + "target_lang": "English", + "terms": [ + { + "source": "博客园", + "target": "cnblogs" + } + ], + "tm_list": [ + { + "source": "代码改变世界", + "target": "Coding changes world" + } + ], + "domains": "This text is a promotion." + } + } +} diff --git a/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/single-generation-message-translation-nosse.request.header.txt b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/single-generation-message-translation-nosse.request.header.txt new file mode 100644 index 0000000..28fb683 --- /dev/null +++ b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/single-generation-message-translation-nosse.request.header.txt @@ -0,0 +1,8 @@ +POST /api/v1/services/aigc/text-generation/generation HTTP/1.1 +Content-Type: application/json +Accept: */* +Cache-Control: no-cache +Host: dashscope.aliyuncs.com +Accept-Encoding: gzip, deflate, br +Connection: keep-alive +Content-Length: 85 diff --git a/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/single-generation-message-translation-nosse.response.body.txt b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/single-generation-message-translation-nosse.response.body.txt new file mode 100644 index 0000000..10105ce --- /dev/null +++ b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/single-generation-message-translation-nosse.response.body.txt @@ -0,0 +1 @@ +{"output":{"finish_reason":"stop","model_name":"qwen-mt-plus","choices":[{"finish_reason":"stop","message":{"role":"assistant","content":"The concept of cnblogs is that coding changes world "}}]},"usage":{"total_tokens":122,"output_tokens":11,"input_tokens":111},"request_id":"bf86e0f9-a8a2-9b32-be8d-ea3cae47c8ea"} diff --git a/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/single-generation-message-translation-nosse.response.header.txt b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/single-generation-message-translation-nosse.response.header.txt new file mode 100644 index 0000000..7fd842a --- /dev/null +++ b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/single-generation-message-translation-nosse.response.header.txt @@ -0,0 +1,15 @@ +HTTP/1.1 200 OK +vary: Origin,Access-Control-Request-Method,Access-Control-Request-Headers, Accept-Encoding +content-type: application/json +x-request-id: bf86e0f9-a8a2-9b32-be8d-ea3cae47c8ea +x-dashscope-call-gateway: true +x-dashscope-finished: true +x-dashscope-timeout: 180 +req-cost-time: 823 +req-arrive-time: 1751904150645 +resp-start-time: 1751904151468 +x-envoy-upstream-service-time: 812 +content-encoding: gzip +date: Mon, 07 Jul 2025 16:02:30 GMT +server: istio-envoy +transfer-encoding: chunked diff --git a/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/socket-speech-synthesizer.continue-task.json b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/socket-speech-synthesizer.continue-task.json new file mode 100644 index 0000000..20ed15f --- /dev/null +++ b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/socket-speech-synthesizer.continue-task.json @@ -0,0 +1,11 @@ +{ + "header": { + "action": "continue-task", + "task_id": "439e0616-2f5b-44e0-8872-0002a066a49c" + }, + "payload": { + "input": { + "text": "代码改变世界" + } + } +} diff --git a/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/socket-speech-synthesizer.finish-task.json b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/socket-speech-synthesizer.finish-task.json new file mode 100644 index 0000000..2efb69a --- /dev/null +++ b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/socket-speech-synthesizer.finish-task.json @@ -0,0 +1,9 @@ +{ + "header": { + "action": "finish-task", + "task_id": "439e0616-2f5b-44e0-8872-0002a066a49c" + }, + "payload": { + "input": {} + } +} diff --git a/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/socket-speech-synthesizer.result-generated.json b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/socket-speech-synthesizer.result-generated.json new file mode 100644 index 0000000..c3111db --- /dev/null +++ b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/socket-speech-synthesizer.result-generated.json @@ -0,0 +1,17 @@ +{ + "header": { + "task_id": "439e0616-2f5b-44e0-8872-0002a066a49c", + "event": "result-generated", + "attributes": { + "request_uuid": "c88301b4-3caa-4f15-94e2-246e84d2e648", + "x-ds-batch-queue-length": "0" + } + }, + "payload": { + "output": { + "sentence": { + "words": [] + } + } + } +} diff --git a/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/socket-speech-synthesizer.run-task.json b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/socket-speech-synthesizer.run-task.json new file mode 100644 index 0000000..482a599 --- /dev/null +++ b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/socket-speech-synthesizer.run-task.json @@ -0,0 +1,24 @@ +{ + "header": { + "action": "run-task", + "task_id": "439e0616-2f5b-44e0-8872-0002a066a49c", + "streaming": "duplex" + }, + "payload": { + "model": "cosyvoice-v1", + "task_group": "audio", + "task": "tts", + "function": "SpeechSynthesizer", + "input": {}, + "parameters": { + "voice": "longxiaochun", + "volume": 50, + "text_type": "PlainText", + "sample_rate": 0, + "rate": 1.1, + "format": "mp3", + "pitch": 1.2, + "enable_ssml": true + } + } +} diff --git a/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/socket-speech-synthesizer.task-failed.json b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/socket-speech-synthesizer.task-failed.json new file mode 100644 index 0000000..27aff61 --- /dev/null +++ b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/socket-speech-synthesizer.task-failed.json @@ -0,0 +1,10 @@ +{ + "header": { + "task_id": "439e0616-2f5b-44e0-8872-0002a066a49c", + "event": "task-failed", + "error_code": "InvalidParameter", + "error_message": "[tts:]Engine return error code: 418", + "attributes": {} + }, + "payload": {} +} diff --git a/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/socket-speech-synthesizer.task-finished.json b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/socket-speech-synthesizer.task-finished.json new file mode 100644 index 0000000..e48f75a --- /dev/null +++ b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/socket-speech-synthesizer.task-finished.json @@ -0,0 +1,20 @@ +{ + "header": { + "task_id": "439e0616-2f5b-44e0-8872-0002a066a49c", + "event": "task-finished", + "attributes": { + "request_uuid": "c88301b4-3caa-4f15-94e2-246e84d2e648", + "x-ds-batch-queue-length": "0" + } + }, + "payload": { + "output": { + "sentence": { + "words": [] + } + }, + "usage": { + "characters": 12 + } + } +} diff --git a/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/socket-speech-synthesizer.task-started.json b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/socket-speech-synthesizer.task-started.json new file mode 100644 index 0000000..ebcddee --- /dev/null +++ b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/socket-speech-synthesizer.task-started.json @@ -0,0 +1,8 @@ +{ + "header": { + "task_id": "439e0616-2f5b-44e0-8872-0002a066a49c", + "event": "task-started", + "attributes": {} + }, + "payload": {} +} diff --git a/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/tts.mp3 b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/tts.mp3 new file mode 100644 index 0000000..6d32a1b Binary files /dev/null and b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/tts.mp3 differ diff --git a/test/Cnblogs.DashScope.Tests.Shared/Utils/Checkers.cs b/test/Cnblogs.DashScope.Tests.Shared/Utils/Checkers.cs index 0e66fbd..df574e7 100644 --- a/test/Cnblogs.DashScope.Tests.Shared/Utils/Checkers.cs +++ b/test/Cnblogs.DashScope.Tests.Shared/Utils/Checkers.cs @@ -4,6 +4,13 @@ namespace Cnblogs.DashScope.Tests.Shared.Utils; public static class Checkers { + public static bool IsJsonEquivalent(ArraySegment socketBuffer, string requestSnapshot) + { + var actual = JsonNode.Parse(socketBuffer); + var expected = JsonNode.Parse(requestSnapshot); + return JsonNode.DeepEquals(actual, expected); + } + public static bool IsJsonEquivalent(HttpContent content, string requestSnapshot) { #pragma warning disable VSTHRD002 diff --git a/test/Cnblogs.DashScope.Tests.Shared/Utils/FakeClientWebSocket.cs b/test/Cnblogs.DashScope.Tests.Shared/Utils/FakeClientWebSocket.cs new file mode 100644 index 0000000..1c73930 --- /dev/null +++ b/test/Cnblogs.DashScope.Tests.Shared/Utils/FakeClientWebSocket.cs @@ -0,0 +1,129 @@ +using System.Net.WebSockets; +using System.Text; +using System.Threading.Channels; +using Cnblogs.DashScope.Core.Internals; + +namespace Cnblogs.DashScope.Tests.Shared.Utils; + +public sealed class FakeClientWebSocket : IClientWebSocket +{ + public List> ServerReceivedMessages { get; } = new(); + + public Channel Server { get; } = + Channel.CreateUnbounded(); + + public Channel ServerBuffer { get; } = Channel.CreateUnbounded(); + + public Queue> Playlist { get; } = new(); + + public bool DisposeCalled { get; private set; } + + public async Task WriteServerCloseAsync() + { + var close = new WebSocketReceiveResult(1, WebSocketMessageType.Close, true); + await Server.Writer.WriteAsync(close); + await Server.Reader.WaitToReadAsync(); + await Task.Delay(50); + } + + public async Task WriteServerMessageAsync(string json) + { + var binary = Encoding.UTF8.GetBytes(json); + await Server.Writer.WriteAsync(new WebSocketReceiveResult(binary.Length, WebSocketMessageType.Text, true)); + + await ServerBuffer.Writer.WriteAsync(binary); + await Server.Reader.WaitToReadAsync(); + await ServerBuffer.Reader.WaitToReadAsync(); + await Task.Delay(50); + } + + public async Task WriteServerMessageAsync(byte[] binary) + { + await Server.Writer.WriteAsync(new WebSocketReceiveResult(binary.Length, WebSocketMessageType.Binary, true)); + + await ServerBuffer.Writer.WriteAsync(binary); + await Server.Reader.WaitToReadAsync(); + await ServerBuffer.Reader.WaitToReadAsync(); + await Task.Delay(50); + } + + private void Dispose(bool disposing) + { + // nothing to release. + if (disposing) + { + DisposeCalled = true; + Server.Writer.TryComplete(); + ServerBuffer.Writer.TryComplete(); + } + } + + /// + public void Dispose() + { + Dispose(true); + } + + /// + public ClientWebSocketOptions Options { get; set; } = null!; + + /// + public WebSocketCloseStatus? CloseStatus { get; set; } + + /// + public Task ConnectAsync(Uri uri, CancellationToken cancellation) + { + // do nothing. + return Task.CompletedTask; + } + + /// + public async Task SendAsync( + ArraySegment buffer, + WebSocketMessageType messageType, + bool endOfMessage, + CancellationToken cancellationToken) + { + ServerReceivedMessages.Add(buffer); + if (Playlist.Count > 0) + { + await Playlist.Dequeue().Invoke(this); + } + } + + /// + public async Task ReceiveAsync( + ArraySegment buffer, + CancellationToken cancellationToken) + { + var timeout = Task.Delay(1000, cancellationToken); + var jsonTask = Server.Reader.WaitToReadAsync(cancellationToken).AsTask(); + var binaryTask = ServerBuffer.Reader.WaitToReadAsync(cancellationToken); + var finishedTask = await Task.WhenAny(jsonTask, timeout); + if (finishedTask == timeout) + { + throw new TimeoutException("waiting for next socket message timeouts"); + } + + if (binaryTask.IsCompleted) + { + var binary = await ServerBuffer.Reader.ReadAsync(cancellationToken); + for (var i = 0; i < binary.Length; i++) + { + buffer[i] = binary[i]; + } + } + + return await Server.Reader.ReadAsync(cancellationToken); + } + + /// + public Task CloseAsync( + WebSocketCloseStatus closeStatus, + string? statusDescription, + CancellationToken cancellationToken) + { + CloseStatus = WebSocketCloseStatus.NormalClosure; + return Task.CompletedTask; + } +} diff --git a/test/Cnblogs.DashScope.Tests.Shared/Utils/Snapshots.SocketRequests.cs b/test/Cnblogs.DashScope.Tests.Shared/Utils/Snapshots.SocketRequests.cs new file mode 100644 index 0000000..79f9b92 --- /dev/null +++ b/test/Cnblogs.DashScope.Tests.Shared/Utils/Snapshots.SocketRequests.cs @@ -0,0 +1,142 @@ +using Cnblogs.DashScope.Core; + +namespace Cnblogs.DashScope.Tests.Shared.Utils; + +public partial class Snapshots +{ + public static class SpeechSynthesizer + { + private const string GroupName = "speech-synthesizer"; + + public static readonly + SocketMessageSnapshot> + RunTask = new( + GroupName, + "run-task", + new DashScopeWebSocketRequest + { + Header = new DashScopeWebSocketRequestHeader + { + Action = "run-task", + Streaming = "duplex", + TaskId = "439e0616-2f5b-44e0-8872-0002a066a49c" + }, + Payload = + new DashScopeWebSocketRequestPayload + { + Task = "tts", + TaskGroup = "audio", + Function = "SpeechSynthesizer", + Model = "cosyvoice-v1", + Parameters = new SpeechSynthesizerParameters + { + EnableSsml = true, + Format = "mp3", + Pitch = 1.2f, + Voice = "longxiaochun", + Volume = 50, + SampleRate = 0, + Rate = 1.1f, + } + } + }); + + public static readonly + SocketMessageSnapshot> + ContinueTask = new( + GroupName, + "continue-task", + new DashScopeWebSocketRequest + { + Header = new DashScopeWebSocketRequestHeader + { + Action = "continue-task", + TaskId = "439e0616-2f5b-44e0-8872-0002a066a49c", + Streaming = null + }, + Payload = + new DashScopeWebSocketRequestPayload + { + Input = new SpeechSynthesizerInput { Text = "代码改变世界" } + } + }); + + public static readonly + SocketMessageSnapshot> + FinishTask = + new( + GroupName, + "finish-task", + new DashScopeWebSocketRequest + { + Header = new DashScopeWebSocketRequestHeader + { + Action = "finish-task", + TaskId = "439e0616-2f5b-44e0-8872-0002a066a49c", + Streaming = null + }, + Payload = + new DashScopeWebSocketRequestPayload { Input = new SpeechSynthesizerInput() } + }); + + public static readonly SocketMessageSnapshot> TaskStarted = + new( + GroupName, + "task-started", + new DashScopeWebSocketResponse( + new DashScopeWebSocketResponseHeader( + "439e0616-2f5b-44e0-8872-0002a066a49c", + "task-started", + null, + null, + new DashScopeWebSocketResponseHeaderAttributes(null)), + new DashScopeWebSocketResponsePayload(null, null))); + + public static readonly SocketMessageSnapshot> TaskFinished = + new( + GroupName, + "task-finished", + new DashScopeWebSocketResponse( + new DashScopeWebSocketResponseHeader( + "439e0616-2f5b-44e0-8872-0002a066a49c", + "task-finished", + null, + null, + new DashScopeWebSocketResponseHeaderAttributes("c88301b4-3caa-4f15-94e2-246e84d2e648")), + new DashScopeWebSocketResponsePayload( + new SpeechSynthesizerOutput(new SpeechSynthesizerOutputSentences(Array.Empty())), + new DashScopeWebSocketResponseUsage(12)))); + + public static readonly SocketMessageSnapshot> TaskFailed = + new( + GroupName, + "task-failed", + new DashScopeWebSocketResponse( + new DashScopeWebSocketResponseHeader( + "439e0616-2f5b-44e0-8872-0002a066a49c", + "task-failed", + "InvalidParameter", + "[tts:]Engine return error code: 418", + new DashScopeWebSocketResponseHeaderAttributes(null)), + new DashScopeWebSocketResponsePayload(null, null))); + + public static readonly SocketMessageSnapshot> + ResultGenerated = new( + GroupName, + "result-generated", + new DashScopeWebSocketResponse( + new DashScopeWebSocketResponseHeader( + "439e0616-2f5b-44e0-8872-0002a066a49c", + "result-generated", + null, + null, + new DashScopeWebSocketResponseHeaderAttributes("c88301b4-3caa-4f15-94e2-246e84d2e648")), + new DashScopeWebSocketResponsePayload( + new SpeechSynthesizerOutput(new SpeechSynthesizerOutputSentences(Array.Empty())), + null))); + + public static readonly byte[] AudioTts = + System.IO.File.ReadAllBytes(Path.Combine("RawHttpData", "tts.mp3"))[..1000]; + } +} diff --git a/test/Cnblogs.DashScope.Tests.Shared/Utils/Snapshots.TextGeneration.cs b/test/Cnblogs.DashScope.Tests.Shared/Utils/Snapshots.TextGeneration.cs index 2bf4461..65e5747 100644 --- a/test/Cnblogs.DashScope.Tests.Shared/Utils/Snapshots.TextGeneration.cs +++ b/test/Cnblogs.DashScope.Tests.Shared/Utils/Snapshots.TextGeneration.cs @@ -95,7 +95,7 @@ public static class MessageFormat new TextGenerationInput { Messages = - new List { TextChatMessage.User("请问 1+1 是多少?") }.AsReadOnly() + new List { TextChatMessage.User("请问 1+1 是多少?") } }, Parameters = new TextGenerationParameters { @@ -146,7 +146,7 @@ public static class MessageFormat new TextGenerationInput { Messages = - new List { TextChatMessage.User("请问 1+1 是多少?") }.AsReadOnly() + new List { TextChatMessage.User("请问 1+1 是多少?") } }, Parameters = new TextGenerationParameters { @@ -192,7 +192,7 @@ public static class MessageFormat new TextGenerationInput { Messages = - new List { TextChatMessage.User("请问 1+1 是多少?") }.AsReadOnly() + new List { TextChatMessage.User("请问 1+1 是多少?") } }, Parameters = new TextGenerationParameters { @@ -230,6 +230,180 @@ public static class MessageFormat } }); + public static readonly RequestSnapshot, + ModelResponse> + SingleMessageLogprobs = new( + "single-generation-message-logprobs", + new ModelRequest + { + Model = "qwen-max", + Input = + new TextGenerationInput + { + Messages = + new List { TextChatMessage.User("请问 1+1 是多少?请直接输出结果") } + }, + Parameters = new TextGenerationParameters + { + ResultFormat = "message", + Seed = 1234, + MaxTokens = 1500, + TopP = 0.8f, + TopK = 100, + RepetitionPenalty = 1.1f, + Temperature = 0.85f, + Logprobs = true, + TopLogprobs = 2 + } + }, + new ModelResponse + { + Output = new TextGenerationOutput + { + Choices = + new List + { + new() + { + FinishReason = "stop", + Message = TextChatMessage.Assistant("2"), + Logprobs = new TextGenerationLogprobs( + new List() + { + new( + "2", + new byte[] { 50 }, + 0.0f, + new List() + { + new("2", new byte[] { 50 }, 0.0f) + }), + }) + } + } + }, + RequestId = "1d881da5-0028-9f20-8e7f-6bc7ae891c54", + Usage = new TextGenerationTokenUsage + { + TotalTokens = 21, + OutputTokens = 1, + InputTokens = 20, + PromptTokensDetails = new TextGenerationPromptTokenDetails(0) + } + }); + + public static readonly RequestSnapshot, + ModelResponse> + SingleMessageTranslation = new( + "single-generation-message-translation", + new ModelRequest + { + Model = "qwen-mt-plus", + Input = + new TextGenerationInput + { + Messages = + new List { TextChatMessage.User("博客园的理念是代码改变世界") } + }, + Parameters = new TextGenerationParameters + { + ResultFormat = "message", + IncrementalOutput = false, + TranslationOptions = new TextGenerationTranslationOptions() + { + SourceLang = "Chinese", + TargetLang = "English", + Domains = "This text is a promotion.", + Terms = new List() { new("博客园", "cnblogs") }, + TmList = new List() { new("代码改变世界", "Coding changes world") } + } + } + }, + new ModelResponse + { + Output = new TextGenerationOutput + { + FinishReason = "stop", + Choices = + new List + { + new() + { + FinishReason = "stop", + Message = TextChatMessage.Assistant( + "The concept of cnblogs is that coding changes world "), + } + } + }, + RequestId = "bf86e0f9-a8a2-9b32-be8d-ea3cae47c8ea", + Usage = new TextGenerationTokenUsage + { + TotalTokens = 122, + OutputTokens = 11, + InputTokens = 111, + } + }); + + public static readonly RequestSnapshot, + ModelResponse> + SingleMessageWebSearch = new( + "single-generation-message-search", + new ModelRequest + { + Model = "qwen-max", + Input = + new TextGenerationInput + { + Messages = + new List { TextChatMessage.User("总结博客园 dudu 的最新博客") } + }, + Parameters = new TextGenerationParameters + { + ResultFormat = "message", + EnableSearch = true, + SearchOptions = new TextGenerationSearchOptions() + { + EnableSource = true, + EnableCitation = true, + CitationFormat = "[ref_]", + ForcedSearch = true, + SearchStrategy = "standard" + } + } + }, + new ModelResponse + { + Output = new TextGenerationOutput + { + Choices = + new List + { + new() + { + FinishReason = "stop", + Message = TextChatMessage.Assistant( + "截至2025年6月7日,博客园的dudu站长发布的内容包括了技术分享和个人经历总结。以下是对dudu最近博客内容的一个概括:\n\n1. 代码重构经验分享:dudu在一篇博客中分享了他在博客园后台开发过程中遇到的一次代码重构经历。这次重构涉及到两个列表的合并(union),他需要实现一个自定义的`EqualityComparer`,基于列表元素的`Id`字段来进行比较,而不是默认的对象引用比较。这表明dudu在持续关注和改进博客园的技术架构,以确保其高效和可维护性。[ref_2]\n\n2. 开源工具介绍:另一篇博客介绍了名为NBearMapping的开源对象映射工具,该工具可用于不同类型的对象、DataRow以及DataReader之间的数据映射。dudu提到这个工具对于开发者来说非常有用,因为它可以简化数据层与业务逻辑层之间的交互。[ref_3]\n\n此外,还有关于个人与博客园共同成长的感想,提到了在过去20年间,无论是个人还是博客园本身都经历了巨大的变化。dudu也提到了自己正面临一些个人生活中的挑战,并表达了对博客园社区理解和支持的感激之情。[ref_1]\n\n这些博客不仅展示了dudu作为技术人员的专业知识和技术分享的热情,还反映了他对博客园这个平台的深厚感情和个人投入。如果您需要更详细的博客内容或有其他问题,请告知我以便提供进一步的帮助。"), + } + }, + SearchInfo = new TextGenerationWebSearchInfo(new List() + { + new("CSDN - 专业开发者社区", "https://img.alicdn.com/imgextra/i3/O1CN01QA3ndK1maJQ8rZTo1_!!6000000004970-55-tps-32-32.svg", 1, "我与博客园的20年转载", "https://blog.csdn.net/weixin_40884228/article/details/148485212"), + new("博客园", "https://img.alicdn.com/imgextra/i2/O1CN01FzHbv01o253A3z2Gd_!!6000000005166-55-tps-32-32.svg", 2, "dudu - 博客园", "https://www.cnblogs.com/dudu"), + new("博客园", "https://img.alicdn.com/imgextra/i2/O1CN01FzHbv01o253A3z2Gd_!!6000000005166-55-tps-32-32.svg", 3, "dudu - 博客园", "https://www.cnblogs.com/dudu?page=36"), + new("阿里云官方网站", "https://img.alicdn.com/imgextra/i3/O1CN015NhUWq1Z1sdj3359l_!!6000000003135-55-tps-32-32.svg", 4, "玩转博客园的心路总结 - 阿里云开发者社区", "https://developer.aliyun.com/article/331235"), + new("CSDN - 专业开发者社区", "https://img.alicdn.com/imgextra/i3/O1CN01QA3ndK1maJQ8rZTo1_!!6000000004970-55-tps-32-32.svg", 5, "为.NET程序员打工的站长——博客园dudu 原创", "https://blog.csdn.net/Microsoft_MVP/article/details/2416055") + }) + }, + RequestId = "80753a20-2750-9ab6-bc2a-1b851ef43efc", + Usage = new TextGenerationTokenUsage + { + TotalTokens = 800, + OutputTokens = 304, + InputTokens = 496, + PromptTokensDetails = new TextGenerationPromptTokenDetails(0) + } + }); + public static readonly RequestSnapshot, ModelResponse> SingleMessageJson = new( @@ -242,7 +416,6 @@ public static class MessageFormat { Messages = new List { TextChatMessage.User("请问 1+1 是多少?用 JSON 格式输出。") } - .AsReadOnly() }, Parameters = new TextGenerationParameters { @@ -293,7 +466,7 @@ public static class MessageFormat new TextGenerationInput { Messages = - new List { TextChatMessage.User("请问 1+1 是多少?") }.AsReadOnly() + new List { TextChatMessage.User("请问 1+1 是多少?") } }, Parameters = new TextGenerationParameters { @@ -344,13 +517,14 @@ public static class MessageFormat new TextGenerationInput { Messages = - new List { TextChatMessage.User("请问 1+1 是多少?") }.AsReadOnly() + new List { TextChatMessage.User("请问 1+1 是多少?") } }, Parameters = new TextGenerationParameters { IncrementalOutput = true, ResultFormat = ResultFormats.Message, - EnableThinking = true + EnableThinking = true, + ThinkingBudget = 10 } }, new ModelResponse @@ -364,20 +538,20 @@ public static class MessageFormat { FinishReason = "stop", Message = TextChatMessage.Assistant( - "在数学中,**1 + 1 = 2**,这是基本的算术加法运算。 \n如果是在其他特殊语境下(例如编程中的字符串拼接、二进制计算,或比喻性表达),答案可能不同,但通常默认情况下,1+1的结果是**2**。", + "1+1 等于 **2**。这是数学中最基本的加法运算之一。\n\n如果你有其他关于数学、科学或任何领域的问题,欢迎继续提问!😊", null, null, - "嗯,用户问的是“1+1是多少?”这个问题看起来很简单,但可能需要考虑不同的上下文。首先,在数学中,1+1显然等于2,这是基本的算术。不过有时候可能会有其他解释,比如在编程里,字符串拼接的话结果可能是“11”。或者在某些比喻的情况下,比如两个人合作,可能会有不同的解释。不过用户没有给出具体的场景,所以应该默认是数学问题。\n\n接下来,我需要确认用户的需求。可能的情况是:他们真的在问数学问题,或者测试我的回答是否正确,或者想看看我会不会考虑其他可能性。比如,有些时候人们会开玩笑说1+1等于3,指的是家庭组成,但这种情况可能需要更多上下文。\n\n另外,用户可能有不同的教育背景,比如小孩子刚开始学数学,可能需要更详细的解释,但问题本身太基础,可能不需要深入。或者用户可能是在检查我的基本功能是否正常,所以回答要简洁准确。\n\n还要考虑是否存在其他可能的答案,比如在二进制中,1+1是10,但通常在十进制环境下还是回答2。不过如果用户有特定领域的需求,可能需要进一步询问。但根据问题本身,没有提示其他进制或特殊情境,所以应该以常规回答为主。\n\n总结下来,最稳妥的回答是先给出数学上的答案2,然后简要提到可能的其他情况,但说明通常默认是指数学加法。这样既准确又全面,避免误解。") + "嗯,用户问的是“1+1是多少") } } }, - RequestId = "d21851a2-675b-97a3-9132-2935c31d6ee3", + RequestId = "ab9f3446-9bbf-963e-9754-2d6543343d7e", Usage = new TextGenerationTokenUsage { - TotalTokens = 394, - OutputTokens = 378, + TotalTokens = 69, + OutputTokens = 53, InputTokens = 16, - OutputTokensDetails = new TextGenerationOutputTokenDetails(306) + OutputTokensDetails = new TextGenerationOutputTokenDetails(ReasoningTokens: 10) } }); @@ -392,7 +566,7 @@ public static class MessageFormat new TextGenerationInput { Messages = - new List { TextChatMessage.User("请问 1+1 是多少?") }.AsReadOnly() + new List { TextChatMessage.User("请问 1+1 是多少?") } }, Parameters = new TextGenerationParameters { @@ -443,7 +617,7 @@ public static readonly Input = new TextGenerationInput { Messages = - new List { TextChatMessage.User("杭州现在的天气如何?") }.AsReadOnly() + new List { TextChatMessage.User("杭州现在的天气如何?") } }, Parameters = new TextGenerationParameters { @@ -473,7 +647,7 @@ public static readonly PropertyNameResolvers.LowerSnakeCase }) .Build())) - }.AsReadOnly(), + }, ToolChoice = ToolChoice.FunctionChoice("get_current_weather") } }, @@ -523,7 +697,7 @@ public static readonly Input = new TextGenerationInput { Messages = - new List { TextChatMessage.User("杭州现在的天气如何?") }.AsReadOnly() + new List { TextChatMessage.User("杭州现在的天气如何?") } }, Parameters = new TextGenerationParameters { @@ -550,7 +724,7 @@ public static readonly PropertyNameResolvers.LowerSnakeCase }) .Build())) - }.AsReadOnly(), + }, ToolChoice = ToolChoice.FunctionChoice("get_current_weather") } }, @@ -603,7 +777,7 @@ public static readonly { TextChatMessage.User("请对“春天来了,大地”这句话进行续写,来表达春天的美好和作者的喜悦之情"), TextChatMessage.Assistant("春天来了,大地", true) - }.AsReadOnly() + } }, Parameters = new TextGenerationParameters { @@ -658,7 +832,7 @@ public static readonly TextChatMessage.User("现在请你记住一个数字,42"), TextChatMessage.Assistant("好的,我已经记住了这个数字。"), TextChatMessage.User("请问我刚才提到的数字是多少?") - }.AsReadOnly() + } }, Parameters = new TextGenerationParameters { @@ -715,9 +889,9 @@ public static readonly { "file-fe-WTTG89tIUTd4ByqP3K48R3bn", "file-fe-l92iyRvJm9vHCCfonLckf1o2" - }.AsReadOnly()), + }), TextChatMessage.User("这两个文件是相同的吗?") - }.AsReadOnly() + } }, Parameters = new TextGenerationParameters { diff --git a/test/Cnblogs.DashScope.Tests.Shared/Utils/SocketMessageSnapshot.cs b/test/Cnblogs.DashScope.Tests.Shared/Utils/SocketMessageSnapshot.cs new file mode 100644 index 0000000..7c94879 --- /dev/null +++ b/test/Cnblogs.DashScope.Tests.Shared/Utils/SocketMessageSnapshot.cs @@ -0,0 +1,12 @@ +namespace Cnblogs.DashScope.Tests.Shared.Utils; + +public record SocketMessageSnapshot(string GroupName, string MessageName) +{ + public string GetMessageJson() + { + return File.ReadAllText(Path.Combine("RawHttpData", $"socket-{GroupName}.{MessageName}.json")); + } +} + +public record SocketMessageSnapshot(string GroupName, string MessageName, TMessage Message) + : SocketMessageSnapshot(GroupName, MessageName); diff --git a/test/Cnblogs.DashScope.Tests.Shared/Utils/Sut.cs b/test/Cnblogs.DashScope.Tests.Shared/Utils/Sut.cs index cb42ce2..db1e550 100644 --- a/test/Cnblogs.DashScope.Tests.Shared/Utils/Sut.cs +++ b/test/Cnblogs.DashScope.Tests.Shared/Utils/Sut.cs @@ -1,4 +1,5 @@ using Cnblogs.DashScope.Core; +using Cnblogs.DashScope.Core.Internals; using NSubstitute; using NSubstitute.Extensions; @@ -20,7 +21,27 @@ public static class Sut public static (DashScopeClientCore Client, MockHttpMessageHandler Handler) GetTestClient() { var handler = Substitute.ForPartsOf(); - var client = new DashScopeClientCore(new HttpClient(handler) { BaseAddress = new Uri("https://example.com") }); + var client = new DashScopeClientCore( + new HttpClient(handler) { BaseAddress = new Uri("https://example.com") }, + new DashScopeClientWebSocketPool(new DashScopeClientWebSocketFactory(), new DashScopeOptions())); return (client, handler); } + + // IClientWebSocket is internal, use InternalVisibleToAttribute make it visible to Cnblogs.DashScope.Sdk.UnitTests + internal static async + Task<(DashScopeClientCore Client, DashScopeClientWebSocket ClientWebSocket, FakeClientWebSocket Server)> + GetSocketTestClientAsync() + { + var socket = new FakeClientWebSocket(); + var dsWebSocket = new DashScopeClientWebSocket(socket); + await dsWebSocket.ConnectAsync( + new Uri(DashScopeDefaults.WebsocketApiBaseAddress), + CancellationToken.None); + dsWebSocket.ResetOutput(); + var pool = new DashScopeClientWebSocketPool( + new List { dsWebSocket }, + new DashScopeClientWebSocketFactory()); + var client = new DashScopeClientCore(new HttpClient(), pool); + return (client, dsWebSocket, socket); + } }