From e119dbc24c3236ce773874ba325383a55f93dc6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B2=88=E6=98=9F=E7=B9=81?= Date: Wed, 10 Jul 2024 13:12:15 +0800 Subject: [PATCH 1/3] feat: support file upload and qwen-long --- src/Cnblogs.DashScope.Core/ChatMessage.cs | 29 +++++- .../DashScopeClientCore.cs | 98 +++++++++++++++++-- .../DashScopeDeleteFileResult.cs | 9 ++ src/Cnblogs.DashScope.Core/DashScopeFile.cs | 18 ++++ src/Cnblogs.DashScope.Core/DashScopeFileId.cs | 82 ++++++++++++++++ .../DashScopeFileList.cs | 9 ++ .../IDashScopeClient.cs | 45 ++++++++- .../Internals/ApiLinks.cs | 1 + .../Internals/DashScopeFileIdConvertor.cs | 25 +++++ src/Cnblogs.DashScope.Core/OpenAiError.cs | 10 ++ .../OpenAiErrorResponse.cs | 3 + src/Cnblogs.DashScope.Sdk/QWen/QWenLlm.cs | 7 +- .../QWen/QWenLlmNames.cs | 1 + .../ErrorTests.cs | 20 +++- .../FileSerializationTests.cs | 79 +++++++++++++++ ...ration-message-with-files-sse.request.json | 27 +++++ ...n-message-with-files-sse.response.body.txt | 48 +++++++++ ...message-with-files-sse.response.header.txt | 17 ++++ .../delete-file-nosse.response.body.txt | 1 + .../delete-file-nosse.response.header.txt | 12 +++ .../get-file-nosse.response.body.txt | 1 + .../get-file-nosse.response.header.txt | 12 +++ .../list-files-nosse.response.body.txt | 1 + .../list-files-nosse.response.header.txt | 12 +++ .../RawHttpData/test1.txt | 1 + .../RawHttpData/test2.txt | 1 + .../upload-file-error-nosse.response.body.txt | 1 + ...pload-file-error-nosse.response.header.txt | 12 +++ .../upload-file-nosse.response.body.txt | 1 + .../upload-file-nosse.response.header.txt | 12 +++ .../TextGenerationSerializationTests.cs | 13 ++- .../Utils/Checkers.cs | 15 +++ .../Utils/Snapshots.cs | 93 +++++++++++++++++- 33 files changed, 697 insertions(+), 19 deletions(-) create mode 100644 src/Cnblogs.DashScope.Core/DashScopeDeleteFileResult.cs create mode 100644 src/Cnblogs.DashScope.Core/DashScopeFile.cs create mode 100644 src/Cnblogs.DashScope.Core/DashScopeFileId.cs create mode 100644 src/Cnblogs.DashScope.Core/DashScopeFileList.cs create mode 100644 src/Cnblogs.DashScope.Core/Internals/DashScopeFileIdConvertor.cs create mode 100644 src/Cnblogs.DashScope.Core/OpenAiError.cs create mode 100644 src/Cnblogs.DashScope.Core/OpenAiErrorResponse.cs create mode 100644 test/Cnblogs.DashScope.Sdk.UnitTests/FileSerializationTests.cs create mode 100644 test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/conversation-generation-message-with-files-sse.request.json create mode 100644 test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/conversation-generation-message-with-files-sse.response.body.txt create mode 100644 test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/conversation-generation-message-with-files-sse.response.header.txt create mode 100644 test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/delete-file-nosse.response.body.txt create mode 100644 test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/delete-file-nosse.response.header.txt create mode 100644 test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/get-file-nosse.response.body.txt create mode 100644 test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/get-file-nosse.response.header.txt create mode 100644 test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/list-files-nosse.response.body.txt create mode 100644 test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/list-files-nosse.response.header.txt create mode 100644 test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/test1.txt create mode 100644 test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/test2.txt create mode 100644 test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/upload-file-error-nosse.response.body.txt create mode 100644 test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/upload-file-error-nosse.response.header.txt create mode 100644 test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/upload-file-nosse.response.body.txt create mode 100644 test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/upload-file-nosse.response.header.txt diff --git a/src/Cnblogs.DashScope.Core/ChatMessage.cs b/src/Cnblogs.DashScope.Core/ChatMessage.cs index 8fcfe9d..acbca89 100644 --- a/src/Cnblogs.DashScope.Core/ChatMessage.cs +++ b/src/Cnblogs.DashScope.Core/ChatMessage.cs @@ -1,4 +1,5 @@ -using Cnblogs.DashScope.Core.Internals; +using System.Text.Json.Serialization; +using Cnblogs.DashScope.Core.Internals; namespace Cnblogs.DashScope.Core; @@ -9,4 +10,28 @@ namespace Cnblogs.DashScope.Core; /// The content of this message. /// Used when role is tool, represents the function name of this message generated by. /// Calls to the function. -public record ChatMessage(string Role, string Content, string? Name = null, List? ToolCalls = null) : IMessage; +[method: JsonConstructor] +public record ChatMessage( + string Role, + string Content, + string? Name = null, + List? ToolCalls = null) : IMessage +{ + /// + /// Create chat message from an uploaded DashScope file. + /// + /// The id of the file. + public ChatMessage(DashScopeFileId fileId) + : this("system", fileId.ToUrl()) + { + } + + /// + /// Create chat message from multiple DashScope file. + /// + /// Ids of the files. + public ChatMessage(IEnumerable fileIds) + : this("system", string.Join(',', fileIds.Select(f => f.ToUrl()))) + { + } +} diff --git a/src/Cnblogs.DashScope.Core/DashScopeClientCore.cs b/src/Cnblogs.DashScope.Core/DashScopeClientCore.cs index 3cdc656..b986de5 100644 --- a/src/Cnblogs.DashScope.Core/DashScopeClientCore.cs +++ b/src/Cnblogs.DashScope.Core/DashScopeClientCore.cs @@ -1,4 +1,5 @@ -using System.Net.Http.Headers; +using System.Diagnostics.CodeAnalysis; +using System.Net.Http.Headers; using System.Net.Http.Json; using System.Runtime.CompilerServices; using System.Text; @@ -130,32 +131,32 @@ public async Task ListTasksAsync( if (startTime.HasValue) { - queryString.Append($"start_time={startTime:YYYYMMDDhhmmss}"); + queryString.Append($"&start_time={startTime:YYYYMMDDhhmmss}"); } if (endTime.HasValue) { - queryString.Append($"end_time={endTime:YYYYMMDDhhmmss}"); + queryString.Append($"&end_time={endTime:YYYYMMDDhhmmss}"); } if (string.IsNullOrEmpty(modelName) == false) { - queryString.Append($"model_name={modelName}"); + queryString.Append($"&model_name={modelName}"); } if (status.HasValue) { - queryString.Append($"status={status}"); + queryString.Append($"&status={status}"); } if (pageNo.HasValue) { - queryString.Append($"page_no={pageNo}"); + queryString.Append($"&page_no={pageNo}"); } if (pageSize.HasValue) { - queryString.Append($"page_size={pageSize}"); + queryString.Append($"&page_size={pageSize}"); } var request = BuildRequest(HttpMethod.Get, $"{ApiLinks.Tasks}?{queryString}"); @@ -202,6 +203,41 @@ public async Task + public async Task UploadFileAsync( + Stream file, + string filename, + string purpose = "file-extract", + CancellationToken cancellationToken = default) + { + var form = new MultipartFormDataContent(); + form.Add(new StreamContent(file), "file", filename); + form.Add(new StringContent(purpose), nameof(purpose)); + var request = new HttpRequestMessage(HttpMethod.Post, ApiLinks.Files) { Content = form }; + return (await SendCompatibleAsync(request, cancellationToken))!; + } + + /// + public async Task GetFileAsync(DashScopeFileId id, CancellationToken cancellationToken = default) + { + var request = BuildRequest(HttpMethod.Get, ApiLinks.Files + $"/{id}"); + return (await SendCompatibleAsync(request, cancellationToken))!; + } + + /// + public async Task ListFilesAsync(CancellationToken cancellationToken = default) + { + var request = BuildRequest(HttpMethod.Get, ApiLinks.Files); + return (await SendCompatibleAsync(request, cancellationToken))!; + } + + /// + public async Task DeleteFileAsync(DashScopeFileId id, CancellationToken cancellationToken = default) + { + var request = BuildRequest(HttpMethod.Delete, ApiLinks.Files + $"/{id}"); + return (await SendCompatibleAsync(request, cancellationToken))!; + } + private static HttpRequestMessage BuildSseRequest(HttpMethod method, string url, TPayload payload) where TPayload : class { @@ -239,6 +275,24 @@ private static HttpRequestMessage BuildRequest( return message; } + private async Task SendCompatibleAsync( + HttpRequestMessage message, + CancellationToken cancellationToken) + where TResponse : class + { + var response = await GetSuccessResponseAsync( + message, + r => new DashScopeError() + { + Code = r.Error.Type, + Message = r.Error.Message, + RequestId = string.Empty + }, + HttpCompletionOption.ResponseContentRead, + cancellationToken); + return await response.Content.ReadFromJsonAsync(SerializationOptions, cancellationToken); + } + private async Task SendAsync(HttpRequestMessage message, CancellationToken cancellationToken) where TResponse : class { @@ -286,6 +340,15 @@ private async Task GetSuccessResponseAsync( HttpRequestMessage message, HttpCompletionOption completeOption = HttpCompletionOption.ResponseContentRead, CancellationToken cancellationToken = default) + { + return await GetSuccessResponseAsync(message, f => f, completeOption, cancellationToken); + } + + private async Task GetSuccessResponseAsync( + HttpRequestMessage message, + Func errorMapper, + HttpCompletionOption completeOption = HttpCompletionOption.ResponseContentRead, + CancellationToken cancellationToken = default) { HttpResponseMessage response; try @@ -305,14 +368,31 @@ private async Task GetSuccessResponseAsync( DashScopeError? error = null; try { - error = await response.Content.ReadFromJsonAsync(SerializationOptions, cancellationToken); + var r = await response.Content.ReadFromJsonAsync(SerializationOptions, cancellationToken); + error = r == null ? null : errorMapper.Invoke(r); } catch (Exception) { // ignore } + await ThrowDashScopeExceptionAsync(error, message, response, cancellationToken); + // will never reach here + return response; + } + + [DoesNotReturn] + private static async Task ThrowDashScopeExceptionAsync( + DashScopeError? error, + HttpRequestMessage message, + HttpResponseMessage response, + CancellationToken cancellationToken) + { var errorMessage = error?.Message ?? await response.Content.ReadAsStringAsync(cancellationToken); - throw new DashScopeException(message.RequestUri?.ToString(), (int)response.StatusCode, error, errorMessage); + throw new DashScopeException( + message.RequestUri?.ToString(), + (int)response.StatusCode, + error, + errorMessage); } } diff --git a/src/Cnblogs.DashScope.Core/DashScopeDeleteFileResult.cs b/src/Cnblogs.DashScope.Core/DashScopeDeleteFileResult.cs new file mode 100644 index 0000000..ccce6d4 --- /dev/null +++ b/src/Cnblogs.DashScope.Core/DashScopeDeleteFileResult.cs @@ -0,0 +1,9 @@ +namespace Cnblogs.DashScope.Core; + +/// +/// Result of a delete file action. +/// +/// Always be "file". +/// Deletion result. +/// Deleting file's id. +public record DashScopeDeleteFileResult(string Object, bool Deleted, DashScopeFileId Id); diff --git a/src/Cnblogs.DashScope.Core/DashScopeFile.cs b/src/Cnblogs.DashScope.Core/DashScopeFile.cs new file mode 100644 index 0000000..a544a2c --- /dev/null +++ b/src/Cnblogs.DashScope.Core/DashScopeFile.cs @@ -0,0 +1,18 @@ +namespace Cnblogs.DashScope.Core; + +/// +/// Represents a DashScope file. +/// +/// Id of the file. +/// Always be "file". +/// Total bytes of the file. +/// Unix timestamp(in seconds) of file create time. +/// Name of the file. +/// Purpose of the file. +public record DashScopeFile( + DashScopeFileId Id, + string Object, + int Bytes, + int CreatedAt, + string Filename, + string? Purpose); diff --git a/src/Cnblogs.DashScope.Core/DashScopeFileId.cs b/src/Cnblogs.DashScope.Core/DashScopeFileId.cs new file mode 100644 index 0000000..cffcfdd --- /dev/null +++ b/src/Cnblogs.DashScope.Core/DashScopeFileId.cs @@ -0,0 +1,82 @@ +using System.Text.Json.Serialization; +using Cnblogs.DashScope.Core.Internals; + +namespace Cnblogs.DashScope.Core; + +/// +/// Represents file id of the DashScope file. +/// +[JsonConverter(typeof(DashScopeFileIdConvertor))] +public readonly struct DashScopeFileId +{ + /// + /// Check if two DashScopeFileId equals. + /// + /// + /// + public bool Equals(DashScopeFileId other) + { + return Value == other.Value; + } + + /// + public override bool Equals(object? obj) + { + return obj is DashScopeFileId other && Equals(other); + } + + /// + public override int GetHashCode() + { + return Value.GetHashCode(); + } + + /// + /// Initialize a DashScopeFileId. + /// + /// The id of the file. + public DashScopeFileId(string fileId) + { + Value = fileId; + } + + /// + /// The value of the file id. + /// + public string Value { get; } + + /// + /// Get url for chat messages. + /// + /// Url like fileid://xxxxxxx + public string ToUrl() => "fileid://" + Value; + + /// + public override string ToString() + { + return Value; + } + + /// + /// Convert string to DashScopeFileId implicitly. + /// + /// The string value to convert. + /// + public static implicit operator DashScopeFileId(string value) => new(value); + + /// + /// Check if two file id is same. + /// + /// + /// + /// + public static bool operator ==(DashScopeFileId left, DashScopeFileId right) => left.Value == right.Value; + + /// + /// Check if two file id is not same. + /// + /// + /// + /// + public static bool operator !=(DashScopeFileId left, DashScopeFileId right) => !(left == right); +} diff --git a/src/Cnblogs.DashScope.Core/DashScopeFileList.cs b/src/Cnblogs.DashScope.Core/DashScopeFileList.cs new file mode 100644 index 0000000..447bc23 --- /dev/null +++ b/src/Cnblogs.DashScope.Core/DashScopeFileList.cs @@ -0,0 +1,9 @@ +namespace Cnblogs.DashScope.Core; + +/// +/// Represents a list of DashScope files. +/// +/// Always be "list". +/// True if not reached last page. +/// Items of current page. +public record DashScopeFileList(string Object, bool HasMore, List Data); diff --git a/src/Cnblogs.DashScope.Core/IDashScopeClient.cs b/src/Cnblogs.DashScope.Core/IDashScopeClient.cs index dda6183..4334c43 100644 --- a/src/Cnblogs.DashScope.Core/IDashScopeClient.cs +++ b/src/Cnblogs.DashScope.Core/IDashScopeClient.cs @@ -66,7 +66,7 @@ Task> GetEmbeddingsA CancellationToken cancellationToken = default); /// - /// Create a image synthesis task. + /// Create an image synthesis task. /// /// The input of image synthesis task. /// The cancellation token to use. @@ -130,7 +130,7 @@ Task> TokenizeAsync( CancellationToken cancellationToken = default); /// - /// Create a image generation task. + /// Create an image generation task. /// /// The input of task. /// The cancellation token to use. @@ -149,4 +149,45 @@ public Task CreateBackgroundGenerationTaskAsync( ModelRequest input, CancellationToken cancellationToken = default); + + /// + /// Upload file for model to reference. + /// + /// File data. + /// Name of the file. + /// Purpose of the file, use "file-extract" to allow model access the file. + /// The cancellation token to use. + /// + public Task UploadFileAsync( + Stream file, + string filename, + string purpose = "file-extract", + CancellationToken cancellationToken = default); + + /// + /// Get DashScope file by id. + /// + /// Id of the file. + /// The cancellation token to use. + /// + /// Throws when file not exists, Status will be 404 in this case. + public Task GetFileAsync(DashScopeFileId id, CancellationToken cancellationToken = default); + + /// + /// List DashScope files. + /// + /// The cancellation token to use. + /// + public Task ListFilesAsync(CancellationToken cancellationToken = default); + + /// + /// Delete DashScope file. + /// + /// The id of the file to delete. + /// The cancellation token to use. + /// + /// Throws when file not exists, Status would be 404. + public Task DeleteFileAsync( + DashScopeFileId id, + CancellationToken cancellationToken = default); } diff --git a/src/Cnblogs.DashScope.Core/Internals/ApiLinks.cs b/src/Cnblogs.DashScope.Core/Internals/ApiLinks.cs index dbefc94..22cc689 100644 --- a/src/Cnblogs.DashScope.Core/Internals/ApiLinks.cs +++ b/src/Cnblogs.DashScope.Core/Internals/ApiLinks.cs @@ -10,4 +10,5 @@ internal static class ApiLinks public const string BackgroundGeneration = "services/aigc/background-generation/generation/"; public const string Tasks = "tasks/"; public const string Tokenizer = "tokenizer"; + public const string Files = "/compatible-mode/v1/files"; } diff --git a/src/Cnblogs.DashScope.Core/Internals/DashScopeFileIdConvertor.cs b/src/Cnblogs.DashScope.Core/Internals/DashScopeFileIdConvertor.cs new file mode 100644 index 0000000..d2eb013 --- /dev/null +++ b/src/Cnblogs.DashScope.Core/Internals/DashScopeFileIdConvertor.cs @@ -0,0 +1,25 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Cnblogs.DashScope.Core.Internals; + +internal class DashScopeFileIdConvertor : JsonConverter +{ + /// + public override DashScopeFileId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var id = reader.GetString(); + if (id == null) + { + throw new JsonException("expected a file id, but found null"); + } + + return new DashScopeFileId(id); + } + + /// + public override void Write(Utf8JsonWriter writer, DashScopeFileId value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.Value); + } +} diff --git a/src/Cnblogs.DashScope.Core/OpenAiError.cs b/src/Cnblogs.DashScope.Core/OpenAiError.cs new file mode 100644 index 0000000..de5b319 --- /dev/null +++ b/src/Cnblogs.DashScope.Core/OpenAiError.cs @@ -0,0 +1,10 @@ +namespace Cnblogs.DashScope.Core; + +/// +/// Error from OpenAi compatible API. +/// +/// Error message. +/// Error type. +/// Problem param name. +/// Error code. +public record OpenAiError(string Message, string Type, string? Param, string? Code); diff --git a/src/Cnblogs.DashScope.Core/OpenAiErrorResponse.cs b/src/Cnblogs.DashScope.Core/OpenAiErrorResponse.cs new file mode 100644 index 0000000..d91020e --- /dev/null +++ b/src/Cnblogs.DashScope.Core/OpenAiErrorResponse.cs @@ -0,0 +1,3 @@ +namespace Cnblogs.DashScope.Core; + +public record OpenAiErrorResponse(OpenAiError Error); diff --git a/src/Cnblogs.DashScope.Sdk/QWen/QWenLlm.cs b/src/Cnblogs.DashScope.Sdk/QWen/QWenLlm.cs index 4c830fe..52851f1 100644 --- a/src/Cnblogs.DashScope.Sdk/QWen/QWenLlm.cs +++ b/src/Cnblogs.DashScope.Sdk/QWen/QWenLlm.cs @@ -73,5 +73,10 @@ public enum QWenLlm /// qwen-1.8b-chat, input token limit is 6k. /// // ReSharper disable once InconsistentNaming - QWen1_8Chat = 13 + QWen1_8Chat = 13, + + /// + /// qwen-long, input limit 10,000,000 token + /// + QWenLong = 14 } diff --git a/src/Cnblogs.DashScope.Sdk/QWen/QWenLlmNames.cs b/src/Cnblogs.DashScope.Sdk/QWen/QWenLlmNames.cs index 9ea15fe..d8a12c0 100644 --- a/src/Cnblogs.DashScope.Sdk/QWen/QWenLlmNames.cs +++ b/src/Cnblogs.DashScope.Sdk/QWen/QWenLlmNames.cs @@ -19,6 +19,7 @@ public static string GetModelName(this QWenLlm llm) QWenLlm.QWen7BChat => "qwen-7b-chat", QWenLlm.QWen1_8BLongContextChat => "qwen-1.8b-longcontext-chat", QWenLlm.QWen1_8Chat => "qwen-1.8b-chat", + QWenLlm.QWenLong => "qwen-long", _ => ThrowHelper.UnknownModelName(nameof(llm), llm) }; } diff --git a/test/Cnblogs.DashScope.Sdk.UnitTests/ErrorTests.cs b/test/Cnblogs.DashScope.Sdk.UnitTests/ErrorTests.cs index 8f8296f..497247d 100644 --- a/test/Cnblogs.DashScope.Sdk.UnitTests/ErrorTests.cs +++ b/test/Cnblogs.DashScope.Sdk.UnitTests/ErrorTests.cs @@ -73,9 +73,27 @@ public async Task Error_NetworkError_ExceptionAsync() .Throws(new InvalidOperationException("Network error!")); // Act - var act = async () => await client.GetTextCompletionAsync(Snapshots.TextGeneration.TextFormat.SinglePrompt.RequestModel); + var act = async () + => await client.GetTextCompletionAsync(Snapshots.TextGeneration.TextFormat.SinglePrompt.RequestModel); // Assert (await act.Should().ThrowAsync()).And.Error.Should().BeNull(); } + + [Fact] + public async Task Error_OpenAiCompatibleError_ExceptionAsync() + { + // Arrange + var testCase = Snapshots.Error.UploadErrorNoSse; + var (client, handler) = await Sut.GetTestClientAsync(false, testCase); + + // Act + var act = async () => await client.UploadFileAsync( + Snapshots.File.TestFile.OpenRead(), + Snapshots.File.TestFile.Name, + "other"); + + // Assert + (await act.Should().ThrowAsync()).And.Error.Should().BeEquivalentTo(testCase.ResponseModel); + } } diff --git a/test/Cnblogs.DashScope.Sdk.UnitTests/FileSerializationTests.cs b/test/Cnblogs.DashScope.Sdk.UnitTests/FileSerializationTests.cs new file mode 100644 index 0000000..05faa58 --- /dev/null +++ b/test/Cnblogs.DashScope.Sdk.UnitTests/FileSerializationTests.cs @@ -0,0 +1,79 @@ +using Cnblogs.DashScope.Sdk.UnitTests.Utils; +using FluentAssertions; +using NSubstitute; + +namespace Cnblogs.DashScope.Sdk.UnitTests; + +public class FileSerializationTests +{ + [Fact] + public async Task File_Upload_SuccessAsync() + { + // Arrange + const bool sse = false; + var testCase = Snapshots.File.UploadFileNoSse; + var (client, handler) = await Sut.GetTestClientAsync(sse, testCase); + + // Act + var task = await client.UploadFileAsync(Snapshots.File.TestFile.OpenRead(), Snapshots.File.TestFile.Name); + + // Assert + handler.Received().MockSend( + Arg.Is(r => r.RequestUri!.AbsolutePath == "/compatible-mode/v1/files"), + Arg.Any()); + task.Should().BeEquivalentTo(testCase.ResponseModel); + } + + [Fact] + public async Task File_Get_SuccessAsync() + { + // Arrange + const bool sse = false; + var testCase = Snapshots.File.GetFileNoSse; + var (client, handler) = await Sut.GetTestClientAsync(sse, testCase); + + // Act + var task = await client.GetFileAsync(testCase.ResponseModel.Id); + + // Assert + handler.Received().MockSend( + Arg.Is( + r => r.RequestUri!.AbsolutePath == "/compatible-mode/v1/files/" + testCase.ResponseModel.Id.Value), + Arg.Any()); + task.Should().BeEquivalentTo(testCase.ResponseModel); + } + + [Fact] + public async Task File_List_SuccessAsync() + { + // Arrange + const bool sse = false; + var testCase = Snapshots.File.ListFileNoSse; + var (client, _) = await Sut.GetTestClientAsync(sse, testCase); + + // Act + var list = await client.ListFilesAsync(); + + // Assert + list.Should().BeEquivalentTo(testCase.ResponseModel); + } + + [Fact] + public async Task File_Delete_SuccessAsync() + { + // Arrange + const bool sse = false; + var testCase = Snapshots.File.DeleteFileNoSse; + var (client, handler) = await Sut.GetTestClientAsync(sse, testCase); + + // Act + var task = await client.DeleteFileAsync(testCase.ResponseModel.Id); + + // Assert + handler.Received().MockSend( + Arg.Is( + r => r.RequestUri!.AbsolutePath == "/compatible-mode/v1/files/" + testCase.ResponseModel.Id.Value), + Arg.Any()); + task.Should().BeEquivalentTo(testCase.ResponseModel); + } +} diff --git a/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/conversation-generation-message-with-files-sse.request.json b/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/conversation-generation-message-with-files-sse.request.json new file mode 100644 index 0000000..84982cd --- /dev/null +++ b/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/conversation-generation-message-with-files-sse.request.json @@ -0,0 +1,27 @@ +{ + "model": "qwen-long", + "input": { + "messages": [ + { + "role": "system", + "content": "fileid://file-fe-WTTG89tIUTd4ByqP3K48R3bn,fileid://file-fe-l92iyRvJm9vHCCfonLckf1o2" + }, + { + "role": "user", + "content": "这两个文件是相同的吗?" + } + ] + }, + "parameters": { + "result_format": "message", + "seed": 1234, + "max_tokens": 1500, + "top_p": 0.8, + "top_k": 100, + "repetition_penalty": 1.1, + "temperature": 0.85, + "stop": [[37763, 367]], + "enable_search": false, + "incremental_output": true + } +} diff --git a/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/conversation-generation-message-with-files-sse.response.body.txt b/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/conversation-generation-message-with-files-sse.response.body.txt new file mode 100644 index 0000000..49dbf46 --- /dev/null +++ b/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/conversation-generation-message-with-files-sse.response.body.txt @@ -0,0 +1,48 @@ +id:1 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"content":"你","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":59,"input_tokens":58,"output_tokens":1},"request_id":"7865ae43-8379-9c79-bef6-95050868bc52"} +id:2 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"content":"上传","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":60,"input_tokens":58,"output_tokens":2},"request_id":"7865ae43-8379-9c79-bef6-95050868bc52"} +id:3 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"content":"的","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":61,"input_tokens":58,"output_tokens":3},"request_id":"7865ae43-8379-9c79-bef6-95050868bc52"} +id:4 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"content":"两个文件并不相同。","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":66,"input_tokens":58,"output_tokens":8},"request_id":"7865ae43-8379-9c79-bef6-95050868bc52"} +id:5 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"content":"第一个文件`test1.txt`包含","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":74,"input_tokens":58,"output_tokens":16},"request_id":"7865ae43-8379-9c79-bef6-95050868bc52"} +id:6 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"content":"两行文本,每行都是“","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":82,"input_tokens":58,"output_tokens":24},"request_id":"7865ae43-8379-9c79-bef6-95050868bc52"} +id:7 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"content":"测试”。而第二个文件`test2","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":90,"input_tokens":58,"output_tokens":32},"request_id":"7865ae43-8379-9c79-bef6-95050868bc52"} +id:8 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"content":".txt`只有一行文本,“测试","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":98,"input_tokens":58,"output_tokens":40},"request_id":"7865ae43-8379-9c79-bef6-95050868bc52"} +id:9 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"content":"2”。尽管它们都含有“测试","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":106,"input_tokens":58,"output_tokens":48},"request_id":"7865ae43-8379-9c79-bef6-95050868bc52"} +id:10 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"content":"”这个词,但具体内容和结构不同","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":114,"input_tokens":58,"output_tokens":56},"request_id":"7865ae43-8379-9c79-bef6-95050868bc52"} +id:11 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"content":"。","role":"assistant"},"finish_reason":"null"}]},"usage":{"total_tokens":115,"input_tokens":58,"output_tokens":57},"request_id":"7865ae43-8379-9c79-bef6-95050868bc52"} +id:12 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"content":"","role":"assistant"},"finish_reason":"stop"}]},"usage":{"total_tokens":115,"input_tokens":58,"output_tokens":57},"request_id":"7865ae43-8379-9c79-bef6-95050868bc52"} diff --git a/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/conversation-generation-message-with-files-sse.response.header.txt b/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/conversation-generation-message-with-files-sse.response.header.txt new file mode 100644 index 0000000..237597c --- /dev/null +++ b/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/conversation-generation-message-with-files-sse.response.header.txt @@ -0,0 +1,17 @@ +HTTP/1.1 200 OK +eagleeye-traceid: 801d240f308ad4126c20b7abd4f0c285 +x-request-id: 7865ae43-8379-9c79-bef6-95050868bc52 +content-type: text/event-stream;charset=UTF-8 +x-dashscope-call-gateway: true +x-dashscope-inner-flow-control: verified +x-dashscope-inner-flow-control-usage: verified +x-dashscope-inner-request-priority: 10 +x-dashscope-requestid: 7865ae43-8379-9c79-bef6-95050868bc52 +x-dashscope-finished: false +req-cost-time: 1905 +req-arrive-time: 1720587511815 +resp-start-time: 1720587513721 +x-envoy-upstream-service-time: 1900 +date: Wed, 10 Jul 2024 04:58:33 GMT +server: istio-envoy +transfer-encoding: chunked diff --git a/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/delete-file-nosse.response.body.txt b/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/delete-file-nosse.response.body.txt new file mode 100644 index 0000000..f9cb602 --- /dev/null +++ b/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/delete-file-nosse.response.body.txt @@ -0,0 +1 @@ +{"object":"file","deleted":true,"id":"file-fe-qBKjZKfTx64R9oYmwyovNHBH"} diff --git a/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/delete-file-nosse.response.header.txt b/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/delete-file-nosse.response.header.txt new file mode 100644 index 0000000..134a7f3 --- /dev/null +++ b/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/delete-file-nosse.response.header.txt @@ -0,0 +1,12 @@ +HTTP/1.1 200 OK +x-request-id: 3817b00a-0e6d-90cd-ada4-14a468185a59 +content-type: application/json;charset=UTF-8 +date: Wed, 10 Jul 2024 04:29:31 GMT +req-cost-time: 566 +req-arrive-time: 1720585771814 +resp-start-time: 1720585772380 +x-envoy-upstream-service-time: 565 +content-encoding: gzip +vary: Accept-Encoding +server: istio-envoy +transfer-encoding: chunked diff --git a/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/get-file-nosse.response.body.txt b/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/get-file-nosse.response.body.txt new file mode 100644 index 0000000..8fdf9c4 --- /dev/null +++ b/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/get-file-nosse.response.body.txt @@ -0,0 +1 @@ +{"id":"file-fe-qBKjZKfTx64R9oYmwyovNHBH","object":"file","bytes":6,"created_at":1720582024,"filename":"test1.txt","purpose":"file-extract","status":"processed"} diff --git a/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/get-file-nosse.response.header.txt b/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/get-file-nosse.response.header.txt new file mode 100644 index 0000000..8cf0266 --- /dev/null +++ b/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/get-file-nosse.response.header.txt @@ -0,0 +1,12 @@ +HTTP/1.1 200 OK +x-request-id: 7564ffca-ae8f-9b85-908b-c33ab076a364 +content-type: application/json;charset=UTF-8 +date: Wed, 10 Jul 2024 04:12:14 GMT +req-cost-time: 138 +req-arrive-time: 1720584734533 +resp-start-time: 1720584734671 +x-envoy-upstream-service-time: 137 +content-encoding: gzip +vary: Accept-Encoding +server: istio-envoy +transfer-encoding: chunked diff --git a/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/list-files-nosse.response.body.txt b/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/list-files-nosse.response.body.txt new file mode 100644 index 0000000..cc2b25e --- /dev/null +++ b/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/list-files-nosse.response.body.txt @@ -0,0 +1 @@ +{"object":"list","has_more":false,"data":[{"id":"file-fe-qBKjZKfTx64R9oYmwyovNHBH","object":"file","bytes":6,"created_at":1720582024,"filename":"test1.txt","purpose":"file-extract","status":"processed"},{"id":"file-fe-WTTG89tIUTd4ByqP3K48R3bn","object":"file","bytes":6,"created_at":1720535665,"filename":"test1.txt","purpose":"file-extract","status":"processed"}]} diff --git a/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/list-files-nosse.response.header.txt b/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/list-files-nosse.response.header.txt new file mode 100644 index 0000000..4c0de6c --- /dev/null +++ b/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/list-files-nosse.response.header.txt @@ -0,0 +1,12 @@ +HTTP/1.1 200 OK +x-request-id: 7b920d81-2d97-965e-835d-f3ca7ee25ae4 +content-type: application/json;charset=UTF-8 +date: Wed, 10 Jul 2024 04:24:18 GMT +req-cost-time: 159 +req-arrive-time: 1720585458676 +resp-start-time: 1720585458835 +x-envoy-upstream-service-time: 158 +content-encoding: gzip +vary: Accept-Encoding +server: istio-envoy +transfer-encoding: chunked diff --git a/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/test1.txt b/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/test1.txt new file mode 100644 index 0000000..c795cf6 --- /dev/null +++ b/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/test1.txt @@ -0,0 +1 @@ +description of the file. diff --git a/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/test2.txt b/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/test2.txt new file mode 100644 index 0000000..c795cf6 --- /dev/null +++ b/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/test2.txt @@ -0,0 +1 @@ +description of the file. diff --git a/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/upload-file-error-nosse.response.body.txt b/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/upload-file-error-nosse.response.body.txt new file mode 100644 index 0000000..877aa08 --- /dev/null +++ b/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/upload-file-error-nosse.response.body.txt @@ -0,0 +1 @@ +{"error":{"message":"'purpose' must be 'file-extract'","type":"invalid_request_error","param":"purpose","code":null}} diff --git a/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/upload-file-error-nosse.response.header.txt b/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/upload-file-error-nosse.response.header.txt new file mode 100644 index 0000000..9b4bb00 --- /dev/null +++ b/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/upload-file-error-nosse.response.header.txt @@ -0,0 +1,12 @@ +HTTP/1.1 400 Bad Request +x-request-id: 68a3e920-c12c-94e7-9cd3-b1512bb181a7 +content-type: application/json;charset=UTF-8 +date: Wed, 10 Jul 2024 03:21:28 GMT +req-cost-time: 11 +req-arrive-time: 1720581689406 +resp-start-time: 1720581689418 +x-envoy-upstream-service-time: 9 +content-encoding: gzip +vary: Accept-Encoding +server: istio-envoy +transfer-encoding: chunked diff --git a/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/upload-file-nosse.response.body.txt b/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/upload-file-nosse.response.body.txt new file mode 100644 index 0000000..8fdf9c4 --- /dev/null +++ b/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/upload-file-nosse.response.body.txt @@ -0,0 +1 @@ +{"id":"file-fe-qBKjZKfTx64R9oYmwyovNHBH","object":"file","bytes":6,"created_at":1720582024,"filename":"test1.txt","purpose":"file-extract","status":"processed"} diff --git a/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/upload-file-nosse.response.header.txt b/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/upload-file-nosse.response.header.txt new file mode 100644 index 0000000..99d5d08 --- /dev/null +++ b/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/upload-file-nosse.response.header.txt @@ -0,0 +1,12 @@ +HTTP/1.1 200 OK +x-request-id: 8ef1f38f-fc7b-90f9-bb09-1926c2675299 +content-type: application/json;charset=UTF-8 +date: Wed, 10 Jul 2024 03:27:04 GMT +req-cost-time: 346 +req-arrive-time: 1720582024232 +resp-start-time: 1720582024578 +x-envoy-upstream-service-time: 344 +content-encoding: gzip +vary: Accept-Encoding +server: istio-envoy +transfer-encoding: chunked diff --git a/test/Cnblogs.DashScope.Sdk.UnitTests/TextGenerationSerializationTests.cs b/test/Cnblogs.DashScope.Sdk.UnitTests/TextGenerationSerializationTests.cs index 6e1b537..7df2c1d 100644 --- a/test/Cnblogs.DashScope.Sdk.UnitTests/TextGenerationSerializationTests.cs +++ b/test/Cnblogs.DashScope.Sdk.UnitTests/TextGenerationSerializationTests.cs @@ -92,12 +92,14 @@ public async Task SingleCompletion_MessageFormatSse_SuccessAsync() message.ToString().Should().Be(testCase.ResponseModel.Output.Choices![0].Message.Content); } - [Fact] - public async Task ConversationCompletion_MessageFormatSse_SuccessAsync() + [Theory] + [MemberData(nameof(ConversationMessageFormatData))] + public async Task ConversationCompletion_MessageFormatSse_SuccessAsync( + RequestSnapshot, + ModelResponse> testCase) { // Arrange const bool sse = true; - var testCase = Snapshots.TextGeneration.MessageFormat.ConversationMessageIncremental; var (client, handler) = await Sut.GetTestClientAsync(sse, testCase); // Act @@ -120,4 +122,9 @@ public async Task ConversationCompletion_MessageFormatSse_SuccessAsync() ModelResponse>> SingleGenerationMessageFormatData = new( Snapshots.TextGeneration.MessageFormat.SingleMessage, Snapshots.TextGeneration.MessageFormat.SingleMessageWithTools); + + public static readonly TheoryData, + ModelResponse>> ConversationMessageFormatData = new( + Snapshots.TextGeneration.MessageFormat.ConversationMessageIncremental, + Snapshots.TextGeneration.MessageFormat.ConversationMessageWithFilesIncremental); } diff --git a/test/Cnblogs.DashScope.Sdk.UnitTests/Utils/Checkers.cs b/test/Cnblogs.DashScope.Sdk.UnitTests/Utils/Checkers.cs index 6466b2b..c7b6e36 100644 --- a/test/Cnblogs.DashScope.Sdk.UnitTests/Utils/Checkers.cs +++ b/test/Cnblogs.DashScope.Sdk.UnitTests/Utils/Checkers.cs @@ -12,4 +12,19 @@ public static bool IsJsonEquivalent(HttpContent content, string requestSnapshot) var expected = JsonNode.Parse(requestSnapshot); return JsonNode.DeepEquals(actual, expected); } + + public static bool IsFileUploaded(HttpContent? content, params string[] files) + { + if (content is not MultipartFormDataContent form) + { + return false; + } + + if (form.Count(x => x.GetType() == typeof(StreamContent)) != files.Length) + { + return false; + } + + return true; + } } diff --git a/test/Cnblogs.DashScope.Sdk.UnitTests/Utils/Snapshots.cs b/test/Cnblogs.DashScope.Sdk.UnitTests/Utils/Snapshots.cs index 87e4b77..db846cf 100644 --- a/test/Cnblogs.DashScope.Sdk.UnitTests/Utils/Snapshots.cs +++ b/test/Cnblogs.DashScope.Sdk.UnitTests/Utils/Snapshots.cs @@ -94,6 +94,15 @@ public static readonly Message = "Role must be user or assistant and Content length must be greater than 0", RequestId = "7671ecd8-93cc-9ee9-bc89-739f0fd8b809" }); + + public static readonly RequestSnapshot UploadErrorNoSse = new( + "upload-file-error", + new() + { + Code = "invalid_request_error", + Message = "'purpose' must be 'file-extract'", + RequestId = string.Empty + }); } public static class TextGeneration @@ -320,7 +329,8 @@ public static readonly Message = new( "assistant", string.Empty, - ToolCalls: [ + ToolCalls: + [ new( string.Empty, ToolTypes.Function, @@ -388,6 +398,60 @@ public static readonly InputTokens = 24 } }); + + public static readonly RequestSnapshot, + ModelResponse> + ConversationMessageWithFilesIncremental = new( + "conversation-generation-message-with-files", + new() + { + Model = "qwen-long", + Input = + new() + { + Messages = + [ + new(["file-fe-WTTG89tIUTd4ByqP3K48R3bn", "file-fe-l92iyRvJm9vHCCfonLckf1o2"]), + new("user", "这两个文件是相同的吗?") + ] + }, + Parameters = new TextGenerationParameters + { + ResultFormat = "message", + Seed = 1234, + MaxTokens = 1500, + TopP = 0.8f, + TopK = 100, + RepetitionPenalty = 1.1f, + Temperature = 0.85f, + Stop = new int[][] { [37763, 367] }, + EnableSearch = false, + IncrementalOutput = true + } + }, + new() + { + Output = new() + { + Choices = + [ + new() + { + FinishReason = "stop", + Message = new( + "assistant", + "你上传的两个文件并不相同。第一个文件`test1.txt`包含两行文本,每行都是“测试”。而第二个文件`test2.txt`只有一行文本,“测试2”。尽管它们都含有“测试”这个词,但具体内容和结构不同。") + } + ] + }, + RequestId = "7865ae43-8379-9c79-bef6-95050868bc52", + Usage = new() + { + TotalTokens = 115, + OutputTokens = 57, + InputTokens = 58 + } + }); } } @@ -1102,4 +1166,31 @@ public static readonly } }); } + + public static class File + { + public static readonly FileInfo TestFile = new FileInfo("RawHttpData/test1.txt"); + + public static readonly RequestSnapshot UploadFileNoSse = new( + "upload-file", + new DashScopeFile("file-fe-qBKjZKfTx64R9oYmwyovNHBH", "file", 6, 1720582024, "test1.txt", "file-extract")); + + public static readonly RequestSnapshot GetFileNoSse = new( + "get-file", + new DashScopeFile("file-fe-qBKjZKfTx64R9oYmwyovNHBH", "file", 6, 1720582024, "test1.txt", "file-extract")); + + public static readonly RequestSnapshot ListFileNoSse = new( + "list-files", + new DashScopeFileList( + "list", + false, + [ + new("file-fe-qBKjZKfTx64R9oYmwyovNHBH", "file", 6, 1720582024, "test1.txt", "file-extract"), + new("file-fe-WTTG89tIUTd4ByqP3K48R3bn", "file", 6, 1720535665, "test1.txt", "file-extract") + ])); + + public static readonly RequestSnapshot DeleteFileNoSse = new( + "delete-file", + new DashScopeDeleteFileResult("file", true, "file-fe-qBKjZKfTx64R9oYmwyovNHBH")); + } } From 13d5a978433e6d41994a1dff9c823f1d41a87089 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B2=88=E6=98=9F=E7=B9=81?= Date: Wed, 10 Jul 2024 13:37:12 +0800 Subject: [PATCH 2/3] docs: update sample and README.md for QWen-Long --- README.md | 34 +++++++++++++- README.zh-Hans.md | 32 +++++++++++++ .../Cnblogs.DashScope.Sample.csproj | 6 +++ sample/Cnblogs.DashScope.Sample/Program.cs | 46 +++++++++++++++++++ sample/Cnblogs.DashScope.Sample/SampleType.cs | 5 +- .../SampleTypeDescriptor.cs | 1 + sample/Cnblogs.DashScope.Sample/test.txt | 1 + 7 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 sample/Cnblogs.DashScope.Sample/test.txt diff --git a/README.md b/README.md index 3358cc1..ee04b2b 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ public class YourService(IDashScopeClient client) - Image Synthesis - `CreateWanxImageSynthesisTaskAsync()` and `GetWanxImageSynthesisTaskAsync()` - Image Generation - `CreateWanxImageGenerationTaskAsync()` and `GetWanxImageGenerationTaskAsync()` - Background Image Generation - `CreateWanxBackgroundGenerationTaskAsync()` and `GetWanxBackgroundGenerationTaskAsync()` - +- File API that used by Qwen-Long - `dashScopeClient.UploadFileAsync()` and `dashScopeClient.DeleteFileAsync` # Examples @@ -163,3 +163,35 @@ Console.WriteLine(completion.Output.Choice[0].Message.Content); ``` Append the tool calling result with `tool` role, then model will generate answers based on tool calling result. + + +## QWen-Long with files + +Upload file first. + +```csharp +var file = new FileInfo("test.txt"); +var uploadedFile = await dashScopeClient.UploadFileAsync(file.OpenRead(), file.Name); +``` + +Using uploaded file id in messages. + +```csharp +var history = new List +{ + new(uploadedFile.Id), // use array for multiple files, e.g. [file1.Id, file2.Id] + new("user", "Summarize the content of file.") +} +var parameters = new TextGenerationParameters() +{ + ResultFormat = ResultFormats.Message +}; +var completion = await client.GetQWenChatCompletionAsync(QWenLlm.QWenLong, history, parameters); +Console.WriteLine(completion.Output.Choices[0].Message.Content); +``` + +Delete file if needed + +```csharp +var deletionResult = await dashScopeClient.DeleteFileAsync(uploadedFile.Id); +``` diff --git a/README.zh-Hans.md b/README.zh-Hans.md index 54f33ec..936246f 100644 --- a/README.zh-Hans.md +++ b/README.zh-Hans.md @@ -62,6 +62,7 @@ public class YourService(IDashScopeClient client) - 文生图 - `CreateWanxImageSynthesisTaskAsync()` and `GetWanxImageSynthesisTaskAsync()` - 人像风格重绘 - `CreateWanxImageGenerationTaskAsync()` and `GetWanxImageGenerationTaskAsync()` - 图像背景生成 - `CreateWanxBackgroundGenerationTaskAsync()` and `GetWanxBackgroundGenerationTaskAsync()` +- 适用于 QWen-Long 的文件 API `dashScopeClient.UploadFileAsync()` and `dashScopeClient.DeleteFileAsync` # 示例 @@ -159,3 +160,34 @@ Console.WriteLine(completion.Output.Choice[0].Message.Content) // 现在浙江 ``` 当模型认为应当调用工具时,返回消息中 `ToolCalls` 会提供调用的详情,本地在调用完成后可以把结果以 `tool` 角色返回。 + +## 上传文件(QWen-Long) + +需要先提前将文件上传到 DashScope 来获得 Id。 + +```csharp +var file = new FileInfo("test.txt"); +var uploadedFile = await dashScopeClient.UploadFileAsync(file.OpenRead(), file.Name); +``` + +使用文件 Id 初始化一个消息,内部会转换成 system 角色的一个文件引用。 + +```csharp +var history = new List +{ + new(uploadedFile.Id), // 多文件情况下可以直接传入文件 Id 数组, 例如:[file1.Id, file2.Id] + new("user", "总结一下文件的内容。") +} +var parameters = new TextGenerationParameters() +{ + ResultFormat = ResultFormats.Message +}; +var completion = await client.GetQWenChatCompletionAsync(QWenLlm.QWenLong, history, parameters); +Console.WriteLine(completion.Output.Choices[0].Message.Content); +``` + +如果需要,完成对话后可以使用 API 删除之前上传的文件。 + +```csharp +var deletionResult = await dashScopeClient.DeleteFileAsync(uploadedFile.Id); +``` diff --git a/sample/Cnblogs.DashScope.Sample/Cnblogs.DashScope.Sample.csproj b/sample/Cnblogs.DashScope.Sample/Cnblogs.DashScope.Sample.csproj index 29261b8..0aa0fef 100644 --- a/sample/Cnblogs.DashScope.Sample/Cnblogs.DashScope.Sample.csproj +++ b/sample/Cnblogs.DashScope.Sample/Cnblogs.DashScope.Sample.csproj @@ -12,4 +12,10 @@ + + + Always + + + diff --git a/sample/Cnblogs.DashScope.Sample/Program.cs b/sample/Cnblogs.DashScope.Sample/Program.cs index b770ec9..0572a8c 100644 --- a/sample/Cnblogs.DashScope.Sample/Program.cs +++ b/sample/Cnblogs.DashScope.Sample/Program.cs @@ -39,6 +39,9 @@ case SampleType.ChatCompletionWithTool: await ChatWithToolsAsync(); break; + case SampleType.ChatCompletionWithFiles: + await ChatWithFilesAsync(); + break; } return; @@ -97,6 +100,49 @@ async Task ChatStreamAsync() // ReSharper disable once FunctionNeverReturns } +async Task ChatWithFilesAsync() +{ + var history = new List(); + Console.WriteLine("uploading file \"test.txt\" "); + var file = new FileInfo("test.txt"); + var uploadedFile = await dashScopeClient.UploadFileAsync(file.OpenRead(), file.Name); + Console.WriteLine("file uploaded, id: " + uploadedFile.Id); + Console.WriteLine(); + + var fileMessage = new ChatMessage(uploadedFile.Id); + history.Add(fileMessage); + Console.WriteLine("system > " + fileMessage.Content); + var userPrompt = new ChatMessage("user", "该文件的内容是什么"); + history.Add(userPrompt); + Console.WriteLine("user > " + userPrompt.Content); + var stream = dashScopeClient.GetQWenChatStreamAsync( + QWenLlm.QWenLong, + history, + new TextGenerationParameters { IncrementalOutput = true, ResultFormat = ResultFormats.Message }); + var role = string.Empty; + var message = new StringBuilder(); + await foreach (var modelResponse in stream) + { + var chunk = modelResponse.Output.Choices![0]; + if (string.IsNullOrEmpty(role) && string.IsNullOrEmpty(chunk.Message.Role) == false) + { + role = chunk.Message.Role; + Console.Write(chunk.Message.Role + " > "); + } + + message.Append(chunk.Message.Content); + Console.Write(chunk.Message.Content); + } + + Console.WriteLine(); + history.Add(new ChatMessage(role, message.ToString())); + + Console.WriteLine(); + Console.WriteLine("Deleting file by id: " + uploadedFile.Id); + var result = await dashScopeClient.DeleteFileAsync(uploadedFile.Id); + Console.WriteLine("Deletion result: " + result.Deleted); +} + async Task ChatWithToolsAsync() { var history = new List(); diff --git a/sample/Cnblogs.DashScope.Sample/SampleType.cs b/sample/Cnblogs.DashScope.Sample/SampleType.cs index f016cd0..0d45c06 100644 --- a/sample/Cnblogs.DashScope.Sample/SampleType.cs +++ b/sample/Cnblogs.DashScope.Sample/SampleType.cs @@ -14,5 +14,8 @@ public enum SampleType ChatCompletion, [Description("Conversation with tools")] - ChatCompletionWithTool + ChatCompletionWithTool, + + [Description("Conversation with files")] + ChatCompletionWithFiles } diff --git a/sample/Cnblogs.DashScope.Sample/SampleTypeDescriptor.cs b/sample/Cnblogs.DashScope.Sample/SampleTypeDescriptor.cs index 8ffebbc..e46fe95 100644 --- a/sample/Cnblogs.DashScope.Sample/SampleTypeDescriptor.cs +++ b/sample/Cnblogs.DashScope.Sample/SampleTypeDescriptor.cs @@ -10,6 +10,7 @@ public static string GetDescription(this SampleType sampleType) SampleType.TextCompletionSse => "Simple prompt completion with incremental output", SampleType.ChatCompletion => "Conversation between user and assistant", SampleType.ChatCompletionWithTool => "Function call sample", + SampleType.ChatCompletionWithFiles => "File upload sample using qwen-long", _ => throw new ArgumentOutOfRangeException(nameof(sampleType), sampleType, "Unsupported sample option") }; } diff --git a/sample/Cnblogs.DashScope.Sample/test.txt b/sample/Cnblogs.DashScope.Sample/test.txt new file mode 100644 index 0000000..4e5be2b --- /dev/null +++ b/sample/Cnblogs.DashScope.Sample/test.txt @@ -0,0 +1 @@ +测试内容。 From b691e3577c1c0d778b2918e5f5c1b5a28c569883 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B2=88=E6=98=9F=E7=B9=81?= Date: Wed, 10 Jul 2024 13:38:13 +0800 Subject: [PATCH 3/3] chore: update xml comment --- src/Cnblogs.DashScope.Core/OpenAiErrorResponse.cs | 4 ++++ test/Cnblogs.DashScope.Sdk.UnitTests/ErrorTests.cs | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Cnblogs.DashScope.Core/OpenAiErrorResponse.cs b/src/Cnblogs.DashScope.Core/OpenAiErrorResponse.cs index d91020e..0715dd1 100644 --- a/src/Cnblogs.DashScope.Core/OpenAiErrorResponse.cs +++ b/src/Cnblogs.DashScope.Core/OpenAiErrorResponse.cs @@ -1,3 +1,7 @@ namespace Cnblogs.DashScope.Core; +/// +/// Represents an error response from DashScope compatible-mode API. +/// +/// Error detail. public record OpenAiErrorResponse(OpenAiError Error); diff --git a/test/Cnblogs.DashScope.Sdk.UnitTests/ErrorTests.cs b/test/Cnblogs.DashScope.Sdk.UnitTests/ErrorTests.cs index 497247d..e660852 100644 --- a/test/Cnblogs.DashScope.Sdk.UnitTests/ErrorTests.cs +++ b/test/Cnblogs.DashScope.Sdk.UnitTests/ErrorTests.cs @@ -85,7 +85,7 @@ public async Task Error_OpenAiCompatibleError_ExceptionAsync() { // Arrange var testCase = Snapshots.Error.UploadErrorNoSse; - var (client, handler) = await Sut.GetTestClientAsync(false, testCase); + var (client, _) = await Sut.GetTestClientAsync(false, testCase); // Act var act = async () => await client.UploadFileAsync(