diff --git a/src/Cnblogs.DashScope.Sdk/ChatMessage.cs b/src/Cnblogs.DashScope.Sdk/ChatMessage.cs index 6063dd1..dbb1edc 100644 --- a/src/Cnblogs.DashScope.Sdk/ChatMessage.cs +++ b/src/Cnblogs.DashScope.Sdk/ChatMessage.cs @@ -5,4 +5,7 @@ namespace Cnblogs.DashScope.Sdk; /// /// Represents a chat message between the user and the model. /// -public record ChatMessage(string Role, string Content) : IMessage; +/// The role of this message. +/// The content of this message. +/// Calls to the function. +public record ChatMessage(string Role, string Content, List? ToolCalls = null) : IMessage; diff --git a/src/Cnblogs.DashScope.Sdk/Cnblogs.DashScope.Sdk.csproj b/src/Cnblogs.DashScope.Sdk/Cnblogs.DashScope.Sdk.csproj index 27ea09e..698eb2a 100644 --- a/src/Cnblogs.DashScope.Sdk/Cnblogs.DashScope.Sdk.csproj +++ b/src/Cnblogs.DashScope.Sdk/Cnblogs.DashScope.Sdk.csproj @@ -4,4 +4,7 @@ true Cnblogs;Dashscope;AI;Sdk;Embedding; + + + diff --git a/src/Cnblogs.DashScope.Sdk/FunctionCall.cs b/src/Cnblogs.DashScope.Sdk/FunctionCall.cs new file mode 100644 index 0000000..88c84e4 --- /dev/null +++ b/src/Cnblogs.DashScope.Sdk/FunctionCall.cs @@ -0,0 +1,8 @@ +namespace Cnblogs.DashScope.Sdk; + +/// +/// Represents a call to function. +/// +/// Name of the function to call. +/// Arguments of this call, usually a json string. +public record FunctionCall(string Name, string? Arguments); diff --git a/src/Cnblogs.DashScope.Sdk/FunctionDefinition.cs b/src/Cnblogs.DashScope.Sdk/FunctionDefinition.cs new file mode 100644 index 0000000..d2d47a0 --- /dev/null +++ b/src/Cnblogs.DashScope.Sdk/FunctionDefinition.cs @@ -0,0 +1,11 @@ +using Json.Schema; + +namespace Cnblogs.DashScope.Sdk; + +/// +/// Definition of function that can be called by model. +/// +/// The name of the function. +/// Descriptions about this function that help model to decide when to call this function. +/// The parameters JSON schema. +public record FunctionDefinition(string Name, string Description, JsonSchema? Parameters); diff --git a/src/Cnblogs.DashScope.Sdk/ITextGenerationParameters.cs b/src/Cnblogs.DashScope.Sdk/ITextGenerationParameters.cs index c0e3285..68b6d02 100644 --- a/src/Cnblogs.DashScope.Sdk/ITextGenerationParameters.cs +++ b/src/Cnblogs.DashScope.Sdk/ITextGenerationParameters.cs @@ -42,4 +42,9 @@ public interface ITextGenerationParameters : IIncrementalOutputParameter, ISeedP /// Enable internet search when generation. Defaults to false. /// public bool? EnableSearch { get; } + + /// + /// Available tools for model to call. + /// + public List? Tools { get; } } diff --git a/src/Cnblogs.DashScope.Sdk/TextGenerationInput.cs b/src/Cnblogs.DashScope.Sdk/TextGenerationInput.cs index 5e46d2b..869bae4 100644 --- a/src/Cnblogs.DashScope.Sdk/TextGenerationInput.cs +++ b/src/Cnblogs.DashScope.Sdk/TextGenerationInput.cs @@ -14,4 +14,9 @@ public class TextGenerationInput /// The collection of context messages associated with this chat completions request. /// public IEnumerable? Messages { get; set; } + + /// + /// Available tools for model to use. + /// + public IEnumerable? Tools { get; set; } } diff --git a/src/Cnblogs.DashScope.Sdk/TextGenerationParameters.cs b/src/Cnblogs.DashScope.Sdk/TextGenerationParameters.cs index c4ca20e..d103dee 100644 --- a/src/Cnblogs.DashScope.Sdk/TextGenerationParameters.cs +++ b/src/Cnblogs.DashScope.Sdk/TextGenerationParameters.cs @@ -32,6 +32,9 @@ public class TextGenerationParameters : ITextGenerationParameters /// public bool? EnableSearch { get; set; } + /// + public List? Tools { get; set; } + /// public bool? IncrementalOutput { get; set; } } diff --git a/src/Cnblogs.DashScope.Sdk/ToolCall.cs b/src/Cnblogs.DashScope.Sdk/ToolCall.cs new file mode 100644 index 0000000..43f7cbd --- /dev/null +++ b/src/Cnblogs.DashScope.Sdk/ToolCall.cs @@ -0,0 +1,9 @@ +namespace Cnblogs.DashScope.Sdk; + +/// +/// Represents a call to tool. +/// +/// Id of this tool call. +/// Type of the tool. +/// Not null if type is function. +public record ToolCall(string? Id, string Type, FunctionCall? Function); diff --git a/src/Cnblogs.DashScope.Sdk/ToolDefinition.cs b/src/Cnblogs.DashScope.Sdk/ToolDefinition.cs new file mode 100644 index 0000000..8c5a93d --- /dev/null +++ b/src/Cnblogs.DashScope.Sdk/ToolDefinition.cs @@ -0,0 +1,8 @@ +namespace Cnblogs.DashScope.Sdk; + +/// +/// Definition of a tool that model can call during generation. +/// +/// The type of this tool. Use to get all available options. +/// Not null when is tool. +public record ToolDefinition(string Type, FunctionDefinition? Function); diff --git a/src/Cnblogs.DashScope.Sdk/ToolTypes.cs b/src/Cnblogs.DashScope.Sdk/ToolTypes.cs new file mode 100644 index 0000000..acb2acf --- /dev/null +++ b/src/Cnblogs.DashScope.Sdk/ToolTypes.cs @@ -0,0 +1,12 @@ +namespace Cnblogs.DashScope.Sdk; + +/// +/// Available tool types for . +/// +public static class ToolTypes +{ + /// + /// Function type. + /// + public const string Function = "function"; +} diff --git a/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/single-generation-message-with-tools-nosse.request.json b/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/single-generation-message-with-tools-nosse.request.json new file mode 100644 index 0000000..74addce --- /dev/null +++ b/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/single-generation-message-with-tools-nosse.request.json @@ -0,0 +1,51 @@ +{ + "model": "qwen-max", + "input": { + "messages": [ + { + "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": false, + "tools": [ + { + "type": "function", + "function": { + "name": "get_current_weather", + "description": "获取现在的天气", + "parameters": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "要获取天气的省市名称,例如浙江省杭州市" + }, + "unit": { + "description": "温度单位", + "enum": [ + "Celsius", + "Fahrenheit" + ] + } + }, + "required": [ + "location" + ] + } + } + } + ] + } +} diff --git a/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/single-generation-message-with-tools-nosse.response.body.txt b/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/single-generation-message-with-tools-nosse.response.body.txt new file mode 100644 index 0000000..c1f1ae6 --- /dev/null +++ b/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/single-generation-message-with-tools-nosse.response.body.txt @@ -0,0 +1 @@ +{"output":{"choices":[{"finish_reason":"tool_calls","message":{"role":"assistant","tool_calls":[{"function":{"name":"get_current_weather","arguments":"{\"location\": \"浙江省杭州市\", \"unit\": \"Celsius\"}"},"id":"","type":"function"}],"content":""}}]},"usage":{"total_tokens":36,"output_tokens":31,"input_tokens":5},"request_id":"40b4361e-e936-91b5-879d-355a45d670f8"} diff --git a/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/single-generation-message-with-tools-nosse.response.header.txt b/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/single-generation-message-with-tools-nosse.response.header.txt new file mode 100644 index 0000000..62317c7 --- /dev/null +++ b/test/Cnblogs.DashScope.Sdk.UnitTests/RawHttpData/single-generation-message-with-tools-nosse.response.header.txt @@ -0,0 +1,14 @@ +HTTP/1.1 200 OK +eagleeye-traceid: 7328e5207abf69133abfe3a68446fc2d +content-type: application/json +x-dashscope-call-gateway: true +x-dashscope-experiments: 33e6d810-qwen-max-base-default-imbalance-fix-lua +req-cost-time: 3898 +req-arrive-time: 1710324737299 +resp-start-time: 1710324741198 +x-envoy-upstream-service-time: 3893 +content-encoding: gzip +vary: Accept-Encoding +date: Wed, 13 Mar 2024 10:12:21 GMT +server: istio-envoy +transfer-encoding: chunked diff --git a/test/Cnblogs.DashScope.Sdk.UnitTests/ServiceCollectionInjectorTests.cs b/test/Cnblogs.DashScope.Sdk.UnitTests/ServiceCollectionInjectorTests.cs index fe2a15b..92d9f28 100644 --- a/test/Cnblogs.DashScope.Sdk.UnitTests/ServiceCollectionInjectorTests.cs +++ b/test/Cnblogs.DashScope.Sdk.UnitTests/ServiceCollectionInjectorTests.cs @@ -103,6 +103,27 @@ public void Configuration_CustomSectionName_Inject() httpClient.BaseAddress.Should().BeEquivalentTo(new Uri(ProxyApi)); } + [Fact] + public void Configuration_AddMultipleTime_Replace() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddDashScopeClient(ApiKey, ProxyApi); + services.AddDashScopeClient(ApiKey, ProxyApi); + var provider = services.BuildServiceProvider(); + var httpClient = provider.GetRequiredService().CreateClient(nameof(IDashScopeClient)); + + // Assert + provider.GetRequiredService().Should().NotBeNull().And + .BeOfType(); + httpClient.Should().NotBeNull(); + httpClient.DefaultRequestHeaders.Authorization.Should() + .BeEquivalentTo(new AuthenticationHeaderValue("Bearer", ApiKey)); + httpClient.BaseAddress.Should().BeEquivalentTo(new Uri(ProxyApi)); + } + [Fact] public void Configuration_NoApiKey_Throw() { diff --git a/test/Cnblogs.DashScope.Sdk.UnitTests/TextGenerationSerializationTests.cs b/test/Cnblogs.DashScope.Sdk.UnitTests/TextGenerationSerializationTests.cs index bcd8c73..ea790e4 100644 --- a/test/Cnblogs.DashScope.Sdk.UnitTests/TextGenerationSerializationTests.cs +++ b/test/Cnblogs.DashScope.Sdk.UnitTests/TextGenerationSerializationTests.cs @@ -47,12 +47,14 @@ public async Task SingleCompletion_TextFormatSse_SuccessAsync() message.ToString().Should().Be(testCase.ResponseModel.Output.Text); } - [Fact] - public async Task SingleCompletion_MessageFormatNoSse_SuccessAsync() + [Theory] + [MemberData(nameof(SingleGenerationMessageFormatData))] + public async Task SingleCompletion_MessageFormatNoSse_SuccessAsync( + RequestSnapshot, + ModelResponse> testCase) { // Arrange const bool sse = false; - var testCase = Snapshots.TextGeneration.MessageFormat.SingleMessage; var (client, handler) = await Sut.GetTestClientAsync(sse, testCase); // Act @@ -83,7 +85,9 @@ public async Task SingleCompletion_MessageFormatSse_SuccessAsync() Arg.Is(m => Checkers.IsJsonEquivalent(m.Content!, testCase.GetRequestJson(sse))), Arg.Any()); outputs.SkipLast(1).Should().AllSatisfy(x => x.Output.Choices![0].FinishReason.Should().Be("null")); - outputs.Last().Should().BeEquivalentTo(testCase.ResponseModel, o => o.Excluding(y => y.Output.Choices![0].Message.Content)); + outputs.Last().Should().BeEquivalentTo( + testCase.ResponseModel, + o => o.Excluding(y => y.Output.Choices![0].Message.Content)); message.ToString().Should().Be(testCase.ResponseModel.Output.Choices![0].Message.Content); } @@ -105,7 +109,14 @@ public async Task ConversationCompletion_MessageFormatSse_SuccessAsync() Arg.Is(m => Checkers.IsJsonEquivalent(m.Content!, testCase.GetRequestJson(sse))), Arg.Any()); outputs.SkipLast(1).Should().AllSatisfy(x => x.Output.Choices![0].FinishReason.Should().Be("null")); - outputs.Last().Should().BeEquivalentTo(testCase.ResponseModel, o => o.Excluding(y => y.Output.Choices![0].Message.Content)); + outputs.Last().Should().BeEquivalentTo( + testCase.ResponseModel, + o => o.Excluding(y => y.Output.Choices![0].Message.Content)); message.ToString().Should().Be(testCase.ResponseModel.Output.Choices![0].Message.Content); } + + public static readonly TheoryData, + ModelResponse>> SingleGenerationMessageFormatData = new( + Snapshots.TextGeneration.MessageFormat.SingleMessage, + Snapshots.TextGeneration.MessageFormat.SingleMessageWithTools); } diff --git a/test/Cnblogs.DashScope.Sdk.UnitTests/Utils/GetCurrentWeatherParameters.cs b/test/Cnblogs.DashScope.Sdk.UnitTests/Utils/GetCurrentWeatherParameters.cs new file mode 100644 index 0000000..3d606f3 --- /dev/null +++ b/test/Cnblogs.DashScope.Sdk.UnitTests/Utils/GetCurrentWeatherParameters.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; +using Json.More; +using Json.Schema.Generation; + +namespace Cnblogs.DashScope.Sdk.UnitTests.Utils; + +public record GetCurrentWeatherParameters( + [property: Required] + [property: Description("要获取天气的省市名称,例如浙江省杭州市")] + string Location, + [property: JsonConverter(typeof(EnumStringConverter))] + [property: Description("温度单位")] + TemperatureUnit Unit = TemperatureUnit.Celsius); + +public enum TemperatureUnit +{ + Celsius, + Fahrenheit +} diff --git a/test/Cnblogs.DashScope.Sdk.UnitTests/Utils/Snapshots.cs b/test/Cnblogs.DashScope.Sdk.UnitTests/Utils/Snapshots.cs index a912e9c..4d938c2 100644 --- a/test/Cnblogs.DashScope.Sdk.UnitTests/Utils/Snapshots.cs +++ b/test/Cnblogs.DashScope.Sdk.UnitTests/Utils/Snapshots.cs @@ -1,4 +1,7 @@ -namespace Cnblogs.DashScope.Sdk.UnitTests.Utils; +using Json.Schema; +using Json.Schema.Generation; + +namespace Cnblogs.DashScope.Sdk.UnitTests.Utils; public static class Snapshots { @@ -267,6 +270,75 @@ public static class MessageFormat } }); + public static readonly + RequestSnapshot, + ModelResponse> SingleMessageWithTools = + new( + "single-generation-message-with-tools", + new() + { + Model = "qwen-max", + Input = new() { Messages = [new("user", "杭州现在的天气如何?")] }, + Parameters = new TextGenerationParameters() + { + ResultFormat = "message", + Seed = 1234, + MaxTokens = 1500, + TopP = 0.8f, + TopK = 100, + RepetitionPenalty = 1.1f, + Temperature = 0.85f, + Stop = new([[37763, 367]]), + EnableSearch = false, + IncrementalOutput = false, + Tools = + [ + new ToolDefinition( + "function", + new FunctionDefinition( + "get_current_weather", + "获取现在的天气", + new JsonSchemaBuilder().FromType( + new() + { + PropertyNameResolver = PropertyNameResolvers.LowerSnakeCase + }) + .Build())) + ] + } + }, + new() + { + Output = new() + { + Choices = + [ + new() + { + FinishReason = "tool_calls", + Message = new( + "assistant", + string.Empty, + [ + new( + string.Empty, + ToolTypes.Function, + new( + "get_current_weather", + """{"location": "浙江省杭州市", "unit": "Celsius"}""")) + ]) + } + ] + }, + RequestId = "40b4361e-e936-91b5-879d-355a45d670f8", + Usage = new() + { + InputTokens = 5, + OutputTokens = 31, + TotalTokens = 36 + } + }); + public static readonly RequestSnapshot, ModelResponse> ConversationMessageIncremental = new(