diff --git a/sample/Cnblogs.DashScope.Sample/Cnblogs.DashScope.Sample.csproj b/sample/Cnblogs.DashScope.Sample/Cnblogs.DashScope.Sample.csproj
index fcff303..ad7accd 100644
--- a/sample/Cnblogs.DashScope.Sample/Cnblogs.DashScope.Sample.csproj
+++ b/sample/Cnblogs.DashScope.Sample/Cnblogs.DashScope.Sample.csproj
@@ -23,7 +23,7 @@
-
+
diff --git a/sample/Cnblogs.DashScope.Sample/Program.cs b/sample/Cnblogs.DashScope.Sample/Program.cs
index a0aa669..4189973 100644
--- a/sample/Cnblogs.DashScope.Sample/Program.cs
+++ b/sample/Cnblogs.DashScope.Sample/Program.cs
@@ -190,7 +190,9 @@ async Task ChatStreamAsync()
async Task ChatWithImageAsync()
{
- var image = await File.ReadAllBytesAsync("Lenna.jpg");
+ var image = File.OpenRead("Lenna.jpg");
+ var ossLink = await dashScopeClient.UploadTemporaryFileAsync("qvq-plus", image, "Lenna.jpg");
+ Console.WriteLine($"Successfully uploaded temp file: {ossLink}");
var response = dashScopeClient.GetMultimodalGenerationStreamAsync(
new ModelRequest()
{
@@ -201,7 +203,7 @@ async Task ChatWithImageAsync()
[
MultimodalMessage.User(
[
- MultimodalMessageContent.ImageContent(image, "image/jpeg"),
+ MultimodalMessageContent.ImageContent(ossLink),
MultimodalMessageContent.TextContent("她是谁?")
])
]
diff --git a/src/Cnblogs.DashScope.AI/Cnblogs.DashScope.AI.csproj b/src/Cnblogs.DashScope.AI/Cnblogs.DashScope.AI.csproj
index a5d8117..4685740 100644
--- a/src/Cnblogs.DashScope.AI/Cnblogs.DashScope.AI.csproj
+++ b/src/Cnblogs.DashScope.AI/Cnblogs.DashScope.AI.csproj
@@ -11,8 +11,8 @@
-
-
+
+
diff --git a/src/Cnblogs.DashScope.Core/Cnblogs.DashScope.Core.csproj b/src/Cnblogs.DashScope.Core/Cnblogs.DashScope.Core.csproj
index f337ae6..7dd23dd 100644
--- a/src/Cnblogs.DashScope.Core/Cnblogs.DashScope.Core.csproj
+++ b/src/Cnblogs.DashScope.Core/Cnblogs.DashScope.Core.csproj
@@ -13,7 +13,7 @@
-
+
diff --git a/src/Cnblogs.DashScope.Core/DashScopeClientCore.cs b/src/Cnblogs.DashScope.Core/DashScopeClientCore.cs
index 997ef63..8bded6e 100644
--- a/src/Cnblogs.DashScope.Core/DashScopeClientCore.cs
+++ b/src/Cnblogs.DashScope.Core/DashScopeClientCore.cs
@@ -287,6 +287,75 @@ public async Task CreateSpeechSynthesizerSocketS
return new SpeechSynthesizerSocketSession(socket, modelId);
}
+ ///
+ public Task GetTemporaryUploadPolicyAsync(
+ string modelId,
+ CancellationToken cancellationToken = default)
+ {
+ var request = BuildRequest(HttpMethod.Get, ApiLinks.Uploads + $"?action=getPolicy&model={modelId}");
+ return SendAsync(request, cancellationToken);
+ }
+
+ ///
+ public async Task UploadTemporaryFileAsync(
+ string modelId,
+ Stream fileStream,
+ string filename,
+ CancellationToken cancellationToken = default)
+ {
+ var policy = await GetTemporaryUploadPolicyAsync(modelId, cancellationToken);
+ if (policy is null)
+ {
+ throw new DashScopeException(
+ "/api/v1/upload",
+ 200,
+ null,
+ "GET /api/v1/upload returns empty response, check your connection");
+ }
+
+ return await UploadTemporaryFileAsync(fileStream, filename, policy);
+ }
+
+ ///
+ public async Task UploadTemporaryFileAsync(
+ Stream fileStream,
+ string filename,
+ DashScopeTemporaryUploadPolicy policy)
+ {
+ var key = $"{policy.Data.UploadDir}/{filename}";
+ var form = DashScopeMultipartContent.Create();
+ form.Add(GetFormDataStringContent(policy.Data.OssAccessKeyId, "OSSAccessKeyId"));
+ form.Add(GetFormDataStringContent(policy.Data.Policy, "policy"));
+ form.Add(GetFormDataStringContent(policy.Data.Signature, "Signature"));
+ form.Add(GetFormDataStringContent(key, "key"));
+ form.Add(GetFormDataStringContent(policy.Data.XOssObjectAcl, "x-oss-object-acl"));
+ form.Add(GetFormDataStringContent(policy.Data.XOssForbidOverwrite, "x-oss-forbid-overwrite"));
+ var file = new StreamContent(fileStream);
+ file.Headers.ContentType = null;
+ file.Headers.TryAddWithoutValidation("Content-Disposition", $"form-data; name=\"file\"; filename=\"{filename}\"");
+ file.Headers.TryAddWithoutValidation("Content-Type", "application/octet-stream");
+ form.Add(file);
+ var response = await _httpClient.PostAsync(policy.Data.UploadHost, form);
+ if (response.IsSuccessStatusCode)
+ {
+ return $"oss://{key}";
+ }
+
+ throw new DashScopeException(
+ policy.Data.UploadHost,
+ (int)response.StatusCode,
+ null,
+ await response.Content.ReadAsStringAsync());
+ }
+
+ private static StringContent GetFormDataStringContent(string value, string key)
+ {
+ var content = new StringContent(value);
+ content.Headers.ContentType = null;
+ content.Headers.TryAddWithoutValidation("Content-Disposition", $"form-data; name=\"{key}\"");
+ return content;
+ }
+
private static HttpRequestMessage BuildSseRequest(HttpMethod method, string url, TPayload payload)
where TPayload : class
{
@@ -328,6 +397,11 @@ private static HttpRequestMessage BuildRequest(
message.Headers.Add("X-DashScope-WorkSpace", config.WorkspaceId);
}
+ if (payload is IDashScopeOssUploadConfig ossConfig && ossConfig.EnableOssResolve())
+ {
+ message.Headers.Add("X-DashScope-OssResourceResolve", "enable");
+ }
+
return message;
}
diff --git a/src/Cnblogs.DashScope.Core/DashScopeClientWebSocket.cs b/src/Cnblogs.DashScope.Core/DashScopeClientWebSocket.cs
index 464aa4c..c29a1c0 100644
--- a/src/Cnblogs.DashScope.Core/DashScopeClientWebSocket.cs
+++ b/src/Cnblogs.DashScope.Core/DashScopeClientWebSocket.cs
@@ -20,6 +20,7 @@ public sealed class DashScopeClientWebSocket : IDisposable
};
private readonly IClientWebSocket _socket;
+ // ReSharper disable once NotAccessedField.Local
private Task? _receiveTask;
private TaskCompletionSource _taskStartedSignal = new();
private Channel? _binaryOutput;
diff --git a/src/Cnblogs.DashScope.Core/DashScopeMultipartContent.cs b/src/Cnblogs.DashScope.Core/DashScopeMultipartContent.cs
new file mode 100644
index 0000000..b5477bc
--- /dev/null
+++ b/src/Cnblogs.DashScope.Core/DashScopeMultipartContent.cs
@@ -0,0 +1,127 @@
+using System.Buffers;
+using System.Net;
+using System.Text;
+
+namespace Cnblogs.DashScope.Core;
+
+internal class DashScopeMultipartContent : MultipartContent
+{
+ private const string CrLf = "\r\n";
+ private readonly string _boundary;
+
+ private DashScopeMultipartContent(string boundary)
+ : base("form-data", boundary)
+ {
+ _boundary = boundary;
+ }
+
+ ///
+ protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context)
+ {
+ // Write start boundary.
+ await EncodeStringToStreamAsync(stream, "--" + _boundary + CrLf);
+
+ // Write each nested content.
+ var output = new MemoryStream();
+ var contentIndex = 0;
+ foreach (var content in this)
+ {
+ output.SetLength(0);
+ SerializeHeadersToStream(output, content, writeDivider: contentIndex != 0);
+ output.Position = 0;
+ await output.CopyToAsync(stream);
+ await content.CopyToAsync(stream, context);
+ contentIndex++;
+ }
+
+ // Write footer boundary.
+ await EncodeStringToStreamAsync(stream, CrLf + "--" + _boundary + "--" + CrLf);
+ }
+
+ ///
+ protected override bool TryComputeLength(out long length)
+ {
+ var success = base.TryComputeLength(out length);
+ return success;
+ }
+
+ private void SerializeHeadersToStream(Stream stream, HttpContent content, bool writeDivider)
+ {
+ // Add divider.
+ if (writeDivider)
+ {
+ WriteToStream(stream, CrLf + "--");
+ WriteToStream(stream, _boundary);
+ WriteToStream(stream, CrLf);
+ }
+
+ // Add headers.
+ foreach (var headerPair in content.Headers.NonValidated)
+ {
+ var headerValueEncoding = HeaderEncodingSelector?.Invoke(headerPair.Key, content)
+ ?? Encoding.UTF8;
+
+ WriteToStream(stream, headerPair.Key);
+ WriteToStream(stream, ": ");
+ var delim = string.Empty;
+ foreach (var value in headerPair.Value)
+ {
+ WriteToStream(stream, delim);
+ WriteToStream(stream, value, headerValueEncoding);
+ delim = ", ";
+ }
+
+ WriteToStream(stream, CrLf);
+ }
+
+ WriteToStream(stream, CrLf);
+ }
+
+ private static void WriteToStream(Stream stream, string content) => WriteToStream(stream, content, Encoding.UTF8);
+
+ private static void WriteToStream(Stream stream, string content, Encoding encoding)
+ {
+ const int stackallocThreshold = 1024;
+
+ var maxLength = encoding.GetMaxByteCount(content.Length);
+
+ byte[]? rentedBuffer = null;
+ var buffer = maxLength <= stackallocThreshold
+ ? stackalloc byte[stackallocThreshold]
+ : (rentedBuffer = ArrayPool.Shared.Rent(maxLength));
+
+ try
+ {
+ var written = encoding.GetBytes(content, buffer);
+ stream.Write(buffer.Slice(0, written));
+ }
+ finally
+ {
+ if (rentedBuffer != null)
+ {
+ ArrayPool.Shared.Return(rentedBuffer);
+ }
+ }
+ }
+
+ private static ValueTask EncodeStringToStreamAsync(Stream stream, string input)
+ {
+ var buffer = Encoding.UTF8.GetBytes(input);
+ return stream.WriteAsync(new ReadOnlyMemory(buffer));
+ }
+
+ public static DashScopeMultipartContent Create()
+ {
+ return Create(Guid.NewGuid().ToString());
+ }
+
+ internal static DashScopeMultipartContent Create(string boundary)
+ {
+ var content = new DashScopeMultipartContent(boundary);
+ content.Headers.ContentType = null;
+ content.Headers.TryAddWithoutValidation(
+ "Content-Type",
+ $"multipart/form-data; boundary={boundary}");
+ return content;
+ }
+}
diff --git a/src/Cnblogs.DashScope.Core/DashScopeTemporaryUploadPolicy.cs b/src/Cnblogs.DashScope.Core/DashScopeTemporaryUploadPolicy.cs
new file mode 100644
index 0000000..035b877
--- /dev/null
+++ b/src/Cnblogs.DashScope.Core/DashScopeTemporaryUploadPolicy.cs
@@ -0,0 +1,8 @@
+namespace Cnblogs.DashScope.Core;
+
+///
+/// Represents one response of get upload policy api call.
+///
+/// Unique id for current request.
+/// The grant data.
+public record DashScopeTemporaryUploadPolicy(string RequestId, DashScopeTemporaryUploadPolicyData Data);
diff --git a/src/Cnblogs.DashScope.Core/DashScopeTemporaryUploadPolicyData.cs b/src/Cnblogs.DashScope.Core/DashScopeTemporaryUploadPolicyData.cs
new file mode 100644
index 0000000..e1c147c
--- /dev/null
+++ b/src/Cnblogs.DashScope.Core/DashScopeTemporaryUploadPolicyData.cs
@@ -0,0 +1,26 @@
+namespace Cnblogs.DashScope.Core;
+
+///
+/// Represent data of oss temp file upload grant.
+///
+/// Upload policy.
+/// Upload signature.
+/// Directory that granted to upload.
+/// Hostname that upload to.
+/// Grant's expiration.
+/// Maximum size of file.
+/// Total upload limit of account.
+/// Key used to upload.
+/// Access of the uploaded file.
+/// Can file be overwritten by another file with same name.
+public record DashScopeTemporaryUploadPolicyData(
+ string Policy,
+ string Signature,
+ string UploadDir,
+ string UploadHost,
+ int ExpireInSeconds,
+ int MaxFileSizeMb,
+ int CapacityLimitMb,
+ string OssAccessKeyId,
+ string XOssObjectAcl,
+ string XOssForbidOverwrite);
diff --git a/src/Cnblogs.DashScope.Core/IDashScopeClient.cs b/src/Cnblogs.DashScope.Core/IDashScopeClient.cs
index cb61eb5..540b97f 100644
--- a/src/Cnblogs.DashScope.Core/IDashScopeClient.cs
+++ b/src/Cnblogs.DashScope.Core/IDashScopeClient.cs
@@ -257,4 +257,42 @@ public Task DeleteFileAsync(
public Task CreateSpeechSynthesizerSocketSessionAsync(
string modelId,
CancellationToken cancellationToken = default);
+
+ ///
+ /// Get a temporary upload grant for to access.
+ ///
+ /// The name of the model.
+ ///
+ ///
+ public Task GetTemporaryUploadPolicyAsync(
+ string modelId,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Upload file that granted.
+ ///
+ /// The model's id that can access the file.
+ /// The file data.
+ /// The name of the file.
+ ///
+ /// Oss url of the file.
+ /// Throws if response code is not 200.
+ public Task UploadTemporaryFileAsync(
+ string modelId,
+ Stream fileStream,
+ string filename,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Upload file that granted.
+ ///
+ /// The file data.
+ ///
+ /// The grant info.
+ ///
+ /// Throws if response code is not 200.
+ public Task UploadTemporaryFileAsync(
+ Stream fileStream,
+ string filename,
+ DashScopeTemporaryUploadPolicy policy);
}
diff --git a/src/Cnblogs.DashScope.Core/Internals/ApiLinks.cs b/src/Cnblogs.DashScope.Core/Internals/ApiLinks.cs
index d098719..aebeb4f 100644
--- a/src/Cnblogs.DashScope.Core/Internals/ApiLinks.cs
+++ b/src/Cnblogs.DashScope.Core/Internals/ApiLinks.cs
@@ -9,6 +9,7 @@ internal static class ApiLinks
public const string ImageGeneration = "services/aigc/image-generation/generation";
public const string BackgroundGeneration = "services/aigc/background-generation/generation/";
public const string Tasks = "tasks/";
+ public const string Uploads = "uploads/";
public const string Tokenizer = "tokenizer";
public const string Files = "/compatible-mode/v1/files";
public static string Application(string applicationId) => $"apps/{applicationId}/completion";
diff --git a/src/Cnblogs.DashScope.Core/Internals/IDashScopeOssUploadConfig.cs b/src/Cnblogs.DashScope.Core/Internals/IDashScopeOssUploadConfig.cs
new file mode 100644
index 0000000..01571e2
--- /dev/null
+++ b/src/Cnblogs.DashScope.Core/Internals/IDashScopeOssUploadConfig.cs
@@ -0,0 +1,12 @@
+namespace Cnblogs.DashScope.Core.Internals;
+
+///
+/// Indicates the request have configuration for oss resource resolve.
+///
+public interface IDashScopeOssUploadConfig
+{
+ ///
+ /// Needs resolve oss resource.
+ ///
+ public bool EnableOssResolve();
+}
diff --git a/src/Cnblogs.DashScope.Core/ModelRequest.cs b/src/Cnblogs.DashScope.Core/ModelRequest.cs
index 93fbfff..6109e45 100644
--- a/src/Cnblogs.DashScope.Core/ModelRequest.cs
+++ b/src/Cnblogs.DashScope.Core/ModelRequest.cs
@@ -1,4 +1,6 @@
-namespace Cnblogs.DashScope.Core;
+using Cnblogs.DashScope.Core.Internals;
+
+namespace Cnblogs.DashScope.Core;
///
/// Represents a request for model generation.
@@ -23,7 +25,7 @@ public class ModelRequest
///
/// The input type for this request.
/// The option type for this request.
-public class ModelRequest : ModelRequest
+public class ModelRequest : ModelRequest, IDashScopeOssUploadConfig
where TInput : class
where TParameter : class
{
@@ -31,4 +33,7 @@ public class ModelRequest : ModelRequest
/// Optional configuration of this request.
///
public TParameter? Parameters { get; set; }
+
+ ///
+ public bool EnableOssResolve() => Input is IDashScopeOssUploadConfig config && config.EnableOssResolve();
}
diff --git a/src/Cnblogs.DashScope.Core/MultimodalInput.cs b/src/Cnblogs.DashScope.Core/MultimodalInput.cs
index 251e0ac..e6c0323 100644
--- a/src/Cnblogs.DashScope.Core/MultimodalInput.cs
+++ b/src/Cnblogs.DashScope.Core/MultimodalInput.cs
@@ -1,12 +1,17 @@
-namespace Cnblogs.DashScope.Core;
+using Cnblogs.DashScope.Core.Internals;
+
+namespace Cnblogs.DashScope.Core;
///
/// Represents inputs of a multi-model generation request.
///
-public class MultimodalInput
+public class MultimodalInput : IDashScopeOssUploadConfig
{
///
/// The messages of context, model will generate from last user message.
///
public IEnumerable Messages { get; set; } = Array.Empty();
+
+ ///
+ public bool EnableOssResolve() => Messages.Any(m => m.IsOss());
}
diff --git a/src/Cnblogs.DashScope.Core/MultimodalMessage.cs b/src/Cnblogs.DashScope.Core/MultimodalMessage.cs
index 48bcc44..f4e370e 100644
--- a/src/Cnblogs.DashScope.Core/MultimodalMessage.cs
+++ b/src/Cnblogs.DashScope.Core/MultimodalMessage.cs
@@ -46,4 +46,6 @@ public static MultimodalMessage Assistant(
{
return new MultimodalMessage(DashScopeRoleNames.Assistant, contents, reasoningContent);
}
+
+ internal bool IsOss() => Content.Any(c => c.IsOss());
}
diff --git a/src/Cnblogs.DashScope.Core/MultimodalMessageContent.cs b/src/Cnblogs.DashScope.Core/MultimodalMessageContent.cs
index aee7879..2722009 100644
--- a/src/Cnblogs.DashScope.Core/MultimodalMessageContent.cs
+++ b/src/Cnblogs.DashScope.Core/MultimodalMessageContent.cs
@@ -17,6 +17,8 @@ public record MultimodalMessageContent(
int? MinPixels = null,
int? MaxPixels = null)
{
+ private const string OssSchema = "oss://";
+
///
/// Represents an image content.
///
@@ -78,4 +80,9 @@ public static MultimodalMessageContent VideoContent(IEnumerable videoUrl
{
return new MultimodalMessageContent(Video: videoUrls);
}
+
+ internal bool IsOss()
+ => Image?.StartsWith(OssSchema) == true
+ || Audio?.StartsWith(OssSchema) == true
+ || Video?.Any(v => v.StartsWith(OssSchema)) == true;
}
diff --git a/test/Cnblogs.DashScope.AI.UnitTests/Cnblogs.DashScope.AI.UnitTests.csproj b/test/Cnblogs.DashScope.AI.UnitTests/Cnblogs.DashScope.AI.UnitTests.csproj
index 293114c..514c2c0 100644
--- a/test/Cnblogs.DashScope.AI.UnitTests/Cnblogs.DashScope.AI.UnitTests.csproj
+++ b/test/Cnblogs.DashScope.AI.UnitTests/Cnblogs.DashScope.AI.UnitTests.csproj
@@ -13,7 +13,7 @@
runtime; build; native; contentfiles; analyzers; buildtransitive
-
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
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 2fd5793..11240e0 100644
--- a/test/Cnblogs.DashScope.Sdk.UnitTests/Cnblogs.DashScope.Sdk.UnitTests.csproj
+++ b/test/Cnblogs.DashScope.Sdk.UnitTests/Cnblogs.DashScope.Sdk.UnitTests.csproj
@@ -13,7 +13,7 @@
-
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
diff --git a/test/Cnblogs.DashScope.Sdk.UnitTests/DashScopeClientWebSocketWrapperTests.cs b/test/Cnblogs.DashScope.Sdk.UnitTests/DashScopeClientWebSocketWrapperTests.cs
index 5d1ce82..5cd4355 100644
--- a/test/Cnblogs.DashScope.Sdk.UnitTests/DashScopeClientWebSocketWrapperTests.cs
+++ b/test/Cnblogs.DashScope.Sdk.UnitTests/DashScopeClientWebSocketWrapperTests.cs
@@ -2,7 +2,6 @@
using Cnblogs.DashScope.Tests.Shared.Utils;
using NSubstitute;
using NSubstitute.Extensions;
-using Xunit.Abstractions;
namespace Cnblogs.DashScope.Sdk.UnitTests;
diff --git a/test/Cnblogs.DashScope.Sdk.UnitTests/UploadSerializationTests.cs b/test/Cnblogs.DashScope.Sdk.UnitTests/UploadSerializationTests.cs
new file mode 100644
index 0000000..b53b069
--- /dev/null
+++ b/test/Cnblogs.DashScope.Sdk.UnitTests/UploadSerializationTests.cs
@@ -0,0 +1,87 @@
+using System.Net;
+using Cnblogs.DashScope.Core;
+using Cnblogs.DashScope.Tests.Shared.Utils;
+using NSubstitute;
+
+namespace Cnblogs.DashScope.Sdk.UnitTests;
+
+public class UploadSerializationTests
+{
+ [Fact]
+ public async Task Upload_GetPolicy_SuccessAsync()
+ {
+ // Arrange
+ const bool sse = false;
+ var testCase = Snapshots.Upload.GetPolicyNoSse;
+ var (client, handler) = await Sut.GetTestClientAsync(sse, testCase);
+
+ // Act
+ var task = await client.GetTemporaryUploadPolicyAsync("qwen-vl-plus");
+
+ // Assert
+ handler.Received().MockSend(
+ Arg.Is(r => r.RequestUri!.PathAndQuery.Contains("model=qwen-vl-plus")),
+ Arg.Any());
+ Assert.Equivalent(testCase.ResponseModel, task);
+ }
+
+ [Fact]
+ public async Task Upload_SubmitFileForm_SuccessAsync()
+ {
+ // Arrange
+ var file = Snapshots.File.TestImage;
+ var policy = Snapshots.Upload.GetPolicyNoSse.ResponseModel;
+ var testCase = Snapshots.Upload.UploadTemporaryFileNoSse;
+ var (client, handler) = await Sut.GetTestClientAsync(new HttpResponseMessage(HttpStatusCode.NoContent));
+
+ // Act
+ var ossUri = await client.UploadTemporaryFileAsync(file.OpenRead(), file.Name, policy);
+ var expectedRequestForm = testCase.GetRequestForm(false);
+
+ // Assert
+ handler.Received().MockSend(
+ Arg.Is(r
+ => r.RequestUri == new Uri(policy.Data.UploadHost)
+ && Checkers.CheckFormContent(r, expectedRequestForm)),
+ Arg.Any());
+ Assert.Equal($"oss://{policy.Data.UploadDir}/{file.Name}", ossUri);
+ }
+
+ [Fact]
+ public async Task Upload_GetOssLinkDirectly_SuccessAsync()
+ {
+ // Arrange
+ const bool sse = false;
+ var file = Snapshots.File.TestImage;
+ var policyCase = Snapshots.Upload.GetPolicyNoSse;
+ var testCase = Snapshots.Upload.UploadTemporaryFileNoSse;
+ var (client, handler) = await Sut.GetTestClientAsync(sse, policyCase);
+
+ // Act
+ var uri = await client.UploadTemporaryFileAsync("qwen-vl-plus", file.OpenRead(), file.Name);
+ var expectedRequestForm = testCase.GetRequestForm(false);
+
+ // Assert
+ handler.Received().MockSend(
+ Arg.Is(r
+ => r.RequestUri == new Uri(policyCase.ResponseModel.Data.UploadHost)
+ && Checkers.CheckFormContent(r, expectedRequestForm)),
+ Arg.Any());
+ Assert.Equal($"oss://{policyCase.ResponseModel.Data.UploadDir}/{file.Name}", uri);
+ }
+
+ [Fact]
+ public async Task Upload_GetPolicyFailed_ThrowsAsync()
+ {
+ // Arrange
+ var file = Snapshots.File.TestImage;
+ var (client, _) = await Sut.GetTestClientAsync(
+ new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("null") });
+
+ // Act
+ var act = async () => await client.UploadTemporaryFileAsync("qwen-vl-plus", file.OpenRead(), file.Name);
+
+ // Assert
+ await Assert.ThrowsAsync(act);
+ }
+}
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 0f05683..f4a861c 100644
--- a/test/Cnblogs.DashScope.Tests.Shared/Cnblogs.DashScope.Tests.Shared.csproj
+++ b/test/Cnblogs.DashScope.Tests.Shared/Cnblogs.DashScope.Tests.Shared.csproj
@@ -22,6 +22,9 @@
Always
+
+ PreserveNewest
+
diff --git a/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/Lenna.jpg b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/Lenna.jpg
new file mode 100644
index 0000000..4030eed
Binary files /dev/null and b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/Lenna.jpg differ
diff --git a/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/get-upload-policy-nosse.header.txt b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/get-upload-policy-nosse.header.txt
new file mode 100644
index 0000000..2768393
--- /dev/null
+++ b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/get-upload-policy-nosse.header.txt
@@ -0,0 +1,6 @@
+GET /api/v1/uploads?action=getPolicy&model=qwen-vl-plus HTTP/1.1
+Accept: */*
+Cache-Control: no-cache
+Host: dashscope.aliyuncs.com
+Accept-Encoding: gzip, deflate, br
+Connection: keep-alive
diff --git a/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/get-upload-policy-nosse.response.body.txt b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/get-upload-policy-nosse.response.body.txt
new file mode 100644
index 0000000..fec31a7
--- /dev/null
+++ b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/get-upload-policy-nosse.response.body.txt
@@ -0,0 +1 @@
+{"data":{"policy":"eyJleHBpcmF0aW9uIjoiMjAyNS0wNy0xMlQxMjoxMDoyNC40ODhaIiwiY29uZGl0aW9ucyI6W1siY29udGVudC1sZW5ndGgtcmFuZ2UiLDAsMTA3Mzc0MTgyNF0sWyJzdGFydHMtd2l0aCIsIiRrZXkiLCJkYXNoc2NvcGUtaW5zdGFudFwvNTJhZmUwNzdmYjQ4MjVjNmQ3NDQxMTc1OGNiMWFiOThcLzIwMjUtMDctMTJcL2I3NDRmNGY4LTFhOWMtOWM2Yi05NTBkLTBkMzI3ZTMzMWYyZiJdLHsiYnVja2V0IjoiZGFzaHNjb3BlLWZpbGUtbWdyIn0seyJ4LW9zcy1vYmplY3QtYWNsIjoicHJpdmF0ZSJ9LHsieC1vc3MtZm9yYmlkLW92ZXJ3cml0ZSI6InRydWUifV19","signature":"n3dNX/aD3+WAly0QgzsURfiIk00=","upload_dir":"dashscope-instant/52afe077fb4825c6d74411758cb1ab98/2025-07-12/b744f4f8-1a9c-9c6b-950d-0d327e331f2f","upload_host":"https://dashscope-file-mgr.oss-cn-beijing.aliyuncs.com","expire_in_seconds":300,"max_file_size_mb":1024,"capacity_limit_mb":999999999,"oss_access_key_id":"LTAI5tG7vL6zZFFbuNrkCjdo","x_oss_object_acl":"private","x_oss_forbid_overwrite":"true"},"request_id":"b744f4f8-1a9c-9c6b-950d-0d327e331f2f"}
diff --git a/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/get-upload-policy-nosse.response.header.txt b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/get-upload-policy-nosse.response.header.txt
new file mode 100644
index 0000000..78442cd
--- /dev/null
+++ b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/get-upload-policy-nosse.response.header.txt
@@ -0,0 +1,11 @@
+HTTP/1.1 200 OK
+content-type: application/json
+req-cost-time: 16
+req-arrive-time: 1752320835878
+resp-start-time: 1752320835894
+x-envoy-upstream-service-time: 7
+content-encoding: gzip
+vary: Accept-Encoding
+date: Sat, 12 Jul 2025 11:47:15 GMT
+server: istio-envoy
+transfer-encoding: chunked
diff --git a/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/upload-temporary-file-nosse.request.body.txt b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/upload-temporary-file-nosse.request.body.txt
new file mode 100644
index 0000000..11a3297
--- /dev/null
+++ b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/upload-temporary-file-nosse.request.body.txt
@@ -0,0 +1,28 @@
+--5aa22a67-eae4-4c54-8f62-c486fefd11a5
+Content-Type: text/plain; charset=utf-8
+Content-Disposition: form-data; name=OSSAccessKeyId
+LTAI5tG7vL6zZFFbuNrkCjdo
+--5aa22a67-eae4-4c54-8f62-c486fefd11a5
+Content-Type: text/plain; charset=utf-8
+Content-Disposition: form-data; name=policy
+eyJleHBpcmF0aW9uIjoiMjAyNS0wNy0xMlQxMjoxMDoyNC40ODhaIiwiY29uZGl0aW9ucyI6W1siY29udGVudC1sZW5ndGgtcmFuZ2UiLDAsMTA3Mzc0MTgyNF0sWyJzdGFydHMtd2l0aCIsIiRrZXkiLCJkYXNoc2NvcGUtaW5zdGFudFwvNTJhZmUwNzdmYjQ4MjVjNmQ3NDQxMTc1OGNiMWFiOThcLzIwMjUtMDctMTJcL2I3NDRmNGY4LTFhOWMtOWM2Yi05NTBkLTBkMzI3ZTMzMWYyZiJdLHsiYnVja2V0IjoiZGFzaHNjb3BlLWZpbGUtbWdyIn0seyJ4LW9zcy1vYmplY3QtYWNsIjoicHJpdmF0ZSJ9LHsieC1vc3MtZm9yYmlkLW92ZXJ3cml0ZSI6InRydWUifV19
+--5aa22a67-eae4-4c54-8f62-c486fefd11a5
+Content-Type: text/plain; charset=utf-8
+Content-Disposition: form-data; name=Signature
+n3dNX/aD3+WAly0QgzsURfiIk00=
+--5aa22a67-eae4-4c54-8f62-c486fefd11a5
+Content-Type: text/plain; charset=utf-8
+Content-Disposition: form-data; name=key
+dashscope-instant/52afe077fb4825c6d74411758cb1ab98/2025-07-12/b744f4f8-1a9c-9c6b-950d-0d327e331f2f/Lenna.jpg
+--5aa22a67-eae4-4c54-8f62-c486fefd11a5
+Content-Type: text/plain; charset=utf-8
+Content-Disposition: form-data; name=x-oss-object-acl
+private
+--5aa22a67-eae4-4c54-8f62-c486fefd11a5
+Content-Type: text/plain; charset=utf-8
+Content-Disposition: form-data; name=x-oss-forbid-overwrite
+true
+--5aa22a67-eae4-4c54-8f62-c486fefd11a5
+Content-Disposition: form-data; name=file
+1
+--5aa22a67-eae4-4c54-8f62-c486fefd11a5--
diff --git a/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/upload-temporary-file-nosse.request.header.txt b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/upload-temporary-file-nosse.request.header.txt
new file mode 100644
index 0000000..0074231
--- /dev/null
+++ b/test/Cnblogs.DashScope.Tests.Shared/RawHttpData/upload-temporary-file-nosse.request.header.txt
@@ -0,0 +1,8 @@
+POST / HTTP/1.1
+Accept: */*
+Cache-Control: no-cache
+Host: dashscope-file-mgr.oss-cn-beijing.aliyuncs.com
+Accept-Encoding: gzip, deflate, br
+Connection: keep-alive
+Content-Type: multipart/form-data; boundary=5aa22a67-eae4-4c54-8f62-c486fefd11a5
+Content-Length: 920956
diff --git a/test/Cnblogs.DashScope.Tests.Shared/Utils/Checkers.cs b/test/Cnblogs.DashScope.Tests.Shared/Utils/Checkers.cs
index df574e7..3db4d7e 100644
--- a/test/Cnblogs.DashScope.Tests.Shared/Utils/Checkers.cs
+++ b/test/Cnblogs.DashScope.Tests.Shared/Utils/Checkers.cs
@@ -11,6 +11,40 @@ public static bool IsJsonEquivalent(ArraySegment socketBuffer, string requ
return JsonNode.DeepEquals(actual, expected);
}
+ public static bool CheckFormContent(HttpRequestMessage message, ICollection contents)
+ {
+ if (message.Content is not MultipartContent formContent)
+ {
+ return false;
+ }
+
+ foreach (var httpContent in formContent)
+ {
+ // check field name
+ var fieldName = httpContent.Headers.ContentDisposition!.Name?.Trim('"');
+ var expectedField = contents.FirstOrDefault(f => f.Headers.ContentDisposition!.Name == fieldName);
+ if (expectedField is null)
+ {
+ return false;
+ }
+
+ if (httpContent is not StringContent)
+ {
+ continue;
+ }
+
+#pragma warning disable VSTHRD002
+ var stringEqual = expectedField.ReadAsStringAsync().Result == httpContent.ReadAsStringAsync().Result;
+#pragma warning restore VSTHRD002
+ if (stringEqual == false)
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
public static bool IsJsonEquivalent(HttpContent content, string requestSnapshot)
{
#pragma warning disable VSTHRD002
diff --git a/test/Cnblogs.DashScope.Tests.Shared/Utils/RequestSnapshot.cs b/test/Cnblogs.DashScope.Tests.Shared/Utils/RequestSnapshot.cs
index 4c01bc1..cdd3ed8 100644
--- a/test/Cnblogs.DashScope.Tests.Shared/Utils/RequestSnapshot.cs
+++ b/test/Cnblogs.DashScope.Tests.Shared/Utils/RequestSnapshot.cs
@@ -3,10 +3,61 @@
namespace Cnblogs.DashScope.Tests.Shared.Utils;
-public record RequestSnapshot(string Name, TResponse ResponseModel)
+public record RequestSnapshot(string Name)
{
+ public string? Boundary { get; set; }
+
protected string GetSnapshotCaseName(bool sse) => $"{Name}-{(sse ? "sse" : "nosse")}";
+ public string GetRequestJson(bool sse)
+ {
+ return GetRequestBody(sse, "json");
+ }
+
+ public List GetRequestForm(bool sse)
+ {
+ var body = GetRequestBody(sse);
+ var blocks = body
+ .Split($"--{Boundary}", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
+ .SkipLast(1)
+ .Select(HttpContent (x) =>
+ {
+ var lines = x.Split("\r\n", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ var data = new StringBuilder();
+ var headers = new Dictionary();
+ foreach (var line in lines)
+ {
+ var colonIndex = line.IndexOf(':');
+ if (colonIndex < 0)
+ {
+ data.Append(line);
+ }
+ else
+ {
+ headers.Add(line[..colonIndex].Trim(), line[(colonIndex + 1)..].Trim());
+ }
+ }
+
+ var content = new StringContent(data.ToString());
+ foreach (var keyValuePair in headers)
+ {
+ content.Headers.TryAddWithoutValidation(keyValuePair.Key, keyValuePair.Value);
+ }
+
+ return content;
+ })
+ .ToList();
+ return blocks;
+ }
+
+ public string GetRequestBody(bool sse, string ext = "txt")
+ {
+ return File.ReadAllText(Path.Combine("RawHttpData", $"{GetSnapshotCaseName(sse)}.request.body.{ext}"));
+ }
+}
+
+public record RequestSnapshot(string Name, TResponse ResponseModel) : RequestSnapshot(Name)
+{
public async Task ToResponseMessageAsync(bool sse)
{
var responseHeader =
@@ -31,10 +82,4 @@ public record RequestSnapshot(
string Name,
TRequest RequestModel,
TResponse ResponseModel)
- : RequestSnapshot(Name, ResponseModel)
-{
- public string GetRequestJson(bool sse)
- {
- return File.ReadAllText(Path.Combine("RawHttpData", $"{GetSnapshotCaseName(sse)}.request.body.json"));
- }
-}
+ : RequestSnapshot(Name, ResponseModel);
diff --git a/test/Cnblogs.DashScope.Tests.Shared/Utils/Snapshots.cs b/test/Cnblogs.DashScope.Tests.Shared/Utils/Snapshots.cs
index fcd442d..e78f08b 100644
--- a/test/Cnblogs.DashScope.Tests.Shared/Utils/Snapshots.cs
+++ b/test/Cnblogs.DashScope.Tests.Shared/Utils/Snapshots.cs
@@ -15,7 +15,7 @@ public static readonly
Input = new TextGenerationInput
{
Messages =
- new List { TextChatMessage.User("代码改变世界") }.AsReadOnly()
+ new List { TextChatMessage.User("代码改变世界") }.AsReadOnly()
},
Model = "qwen-max",
Parameters = new TextGenerationParameters { Seed = 1234 }
@@ -136,6 +136,7 @@ public static readonly
public static class File
{
public static readonly FileInfo TestFile = new("RawHttpData/test1.txt");
+ public static readonly FileInfo TestImage = new("RawHttpData/Lenna.jpg");
public static readonly RequestSnapshot UploadFileNoSse = new(
"upload-file",
@@ -172,4 +173,28 @@ public static class File
"delete-file",
new DashScopeDeleteFileResult("file", true, "file-fe-qBKjZKfTx64R9oYmwyovNHBH"));
}
+
+ public static class Upload
+ {
+ public static readonly RequestSnapshot GetPolicyNoSse = new(
+ "get-upload-policy",
+ new DashScopeTemporaryUploadPolicy(
+ "b744f4f8-1a9c-9c6b-950d-0d327e331f2f",
+ new DashScopeTemporaryUploadPolicyData(
+ "eyJleHBpcmF0aW9uIjoiMjAyNS0wNy0xMlQxMjoxMDoyNC40ODhaIiwiY29uZGl0aW9ucyI6W1siY29udGVudC1sZW5ndGgtcmFuZ2UiLDAsMTA3Mzc0MTgyNF0sWyJzdGFydHMtd2l0aCIsIiRrZXkiLCJkYXNoc2NvcGUtaW5zdGFudFwvNTJhZmUwNzdmYjQ4MjVjNmQ3NDQxMTc1OGNiMWFiOThcLzIwMjUtMDctMTJcL2I3NDRmNGY4LTFhOWMtOWM2Yi05NTBkLTBkMzI3ZTMzMWYyZiJdLHsiYnVja2V0IjoiZGFzaHNjb3BlLWZpbGUtbWdyIn0seyJ4LW9zcy1vYmplY3QtYWNsIjoicHJpdmF0ZSJ9LHsieC1vc3MtZm9yYmlkLW92ZXJ3cml0ZSI6InRydWUifV19",
+ "n3dNX/aD3+WAly0QgzsURfiIk00=",
+ "dashscope-instant/52afe077fb4825c6d74411758cb1ab98/2025-07-12/b744f4f8-1a9c-9c6b-950d-0d327e331f2f",
+ "https://dashscope-file-mgr.oss-cn-beijing.aliyuncs.com",
+ 300,
+ 1024,
+ 999999999,
+ "LTAI5tG7vL6zZFFbuNrkCjdo",
+ "private",
+ "true")));
+
+ public static readonly RequestSnapshot UploadTemporaryFileNoSse = new("upload-temporary-file")
+ {
+ Boundary = "5aa22a67-eae4-4c54-8f62-c486fefd11a5"
+ };
+ }
}
diff --git a/test/Cnblogs.DashScope.Tests.Shared/Utils/Sut.cs b/test/Cnblogs.DashScope.Tests.Shared/Utils/Sut.cs
index db1e550..a24149e 100644
--- a/test/Cnblogs.DashScope.Tests.Shared/Utils/Sut.cs
+++ b/test/Cnblogs.DashScope.Tests.Shared/Utils/Sut.cs
@@ -7,15 +7,21 @@ namespace Cnblogs.DashScope.Tests.Shared.Utils;
public static class Sut
{
+ public static async Task<(IDashScopeClient Client, MockHttpMessageHandler Handler)> GetTestClientAsync(
+ HttpResponseMessage response)
+ {
+ var pair = GetTestClient();
+ pair.Handler.Configure().MockSend(Arg.Any(), Arg.Any())
+ .Returns(response);
+ return pair;
+ }
+
public static async Task<(IDashScopeClient Client, MockHttpMessageHandler Handler)> GetTestClientAsync(
bool sse,
RequestSnapshot testCase)
{
- var pair = GetTestClient();
var expected = await testCase.ToResponseMessageAsync(sse);
- pair.Handler.Configure().MockSend(Arg.Any(), Arg.Any())
- .Returns(expected);
- return pair;
+ return await GetTestClientAsync(expected);
}
public static (DashScopeClientCore Client, MockHttpMessageHandler Handler) GetTestClient()