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(