.NET: fix ChatCompletions Tools serialization (#2043)

* fix serialization in chat completions on tools

* nit
This commit is contained in:
Korolev Dmitry
2025-11-10 12:35:54 +01:00
committed by GitHub
Unverified
parent e7224b5efb
commit 42da3cb6a4
5 changed files with 228 additions and 15 deletions
@@ -18,7 +18,7 @@ internal abstract record Tool
/// <summary>
/// The type of the tool.
/// </summary>
[JsonPropertyName("type")]
[JsonIgnore]
public abstract string Type { get; }
}
@@ -30,7 +30,7 @@ internal sealed record FunctionTool : Tool
/// <summary>
/// The type of the tool. Always "function".
/// </summary>
[JsonPropertyName("type")]
[JsonIgnore]
public override string Type => "function";
/// <summary>
@@ -88,7 +88,7 @@ internal sealed record CustomTool : Tool
/// <summary>
/// The type of the tool. Always "custom".
/// </summary>
[JsonPropertyName("type")]
[JsonIgnore]
public override string Type => "custom";
/// <summary>
@@ -0,0 +1,53 @@
{
"model": "gpt-4o-mini",
"messages": [
{
"role": "user",
"content": "What's the weather like in San Francisco?"
}
],
"max_completion_tokens": 256,
"temperature": 0.7,
"top_p": 1,
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather in a given location",
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "The city and state, e.g. San Francisco, CA"
},
"unit": {
"type": "string",
"enum": [ "celsius", "fahrenheit" ],
"description": "Temperature unit"
}
},
"required": [ "location" ]
}
}
},
{
"type": "function",
"function": {
"name": "get_time",
"description": "Get the current time in a given timezone",
"parameters": {
"type": "object",
"properties": {
"timezone": {
"type": "string",
"description": "The IANA timezone, e.g. America/Los_Angeles"
}
},
"required": [ "timezone" ]
}
}
}
]
}
@@ -0,0 +1,42 @@
{
"id": "chatcmpl-tools-test-001",
"object": "chat.completion",
"created": 1234567890,
"model": "gpt-4o-mini",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_abc123",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"location\": \"San Francisco, CA\", \"unit\": \"fahrenheit\"}"
}
}
]
},
"finish_reason": "tool_calls"
}
],
"usage": {
"prompt_tokens": 85,
"completion_tokens": 32,
"total_tokens": 117,
"prompt_tokens_details": {
"cached_tokens": 0,
"audio_tokens": 0
},
"completion_tokens_details": {
"reasoning_tokens": 0,
"audio_tokens": 0,
"accepted_prediction_tokens": 0,
"rejected_prediction_tokens": 0
}
},
"service_tier": "default"
}
@@ -27,16 +27,4 @@
</None>
</ItemGroup>
<ItemGroup>
<Content Remove="ConformanceTraces\ChatCompletions\function_calling\request.json" />
<Content Remove="ConformanceTraces\ChatCompletions\function_calling\response.json" />
<Content Remove="ConformanceTraces\ChatCompletions\json_mode\request.json" />
<Content Remove="ConformanceTraces\ChatCompletions\json_mode\response.json" />
<Content Remove="ConformanceTraces\ChatCompletions\multi_turn\request.json" />
<Content Remove="ConformanceTraces\ChatCompletions\multi_turn\response.json" />
<Content Remove="ConformanceTraces\ChatCompletions\streaming\request.json" />
<Content Remove="ConformanceTraces\ChatCompletions\system_message\request.json" />
<Content Remove="ConformanceTraces\ChatCompletions\system_message\response.json" />
</ItemGroup>
</Project>
@@ -456,6 +456,136 @@ public sealed class OpenAIChatCompletionsConformanceTests : ConformanceTestBase
Assert.Equal(JsonValueKind.String, jsonRoot.GetProperty("occupation").ValueKind);
}
[Fact]
public async Task ToolsSerializationDeserializationAsync()
{
// Arrange
string requestJson = LoadChatCompletionsTraceFile("tools/request.json");
using var expectedResponseDoc = LoadChatCompletionsTraceDocument("tools/response.json");
HttpClient client = await this.CreateTestServerAsync(
"tools-agent",
"You are a helpful assistant with access to weather and time tools.",
"tool-call",
(msg) => [new FunctionCallContent("call_abc123", "get_weather", new Dictionary<string, object?>() {
{ "location", "San Francisco, CA" },
{ "unit", "fahrenheit" }
})]
);
// Act
HttpResponseMessage httpResponse = await this.SendChatCompletionRequestAsync(client, "tools-agent", requestJson);
using var responseDoc = await ParseResponseAsync(httpResponse);
var response = responseDoc.RootElement;
// Parse the request
using var requestDoc = JsonDocument.Parse(requestJson);
var request = requestDoc.RootElement;
// Assert - Request has tools array with proper structure
AssertJsonPropertyExists(request, "tools");
var tools = request.GetProperty("tools");
Assert.Equal(JsonValueKind.Array, tools.ValueKind);
Assert.Equal(2, tools.GetArrayLength());
// Assert - First tool (get_weather)
var weatherTool = tools[0];
AssertJsonPropertyEquals(weatherTool, "type", "function");
AssertJsonPropertyExists(weatherTool, "function");
var weatherFunction = weatherTool.GetProperty("function");
AssertJsonPropertyEquals(weatherFunction, "name", "get_weather");
AssertJsonPropertyExists(weatherFunction, "description");
AssertJsonPropertyExists(weatherFunction, "parameters");
var weatherParams = weatherFunction.GetProperty("parameters");
AssertJsonPropertyEquals(weatherParams, "type", "object");
AssertJsonPropertyExists(weatherParams, "properties");
AssertJsonPropertyExists(weatherParams, "required");
// Verify location property exists
var properties = weatherParams.GetProperty("properties");
AssertJsonPropertyExists(properties, "location");
AssertJsonPropertyExists(properties, "unit");
// Assert - Second tool (get_time)
var timeTool = tools[1];
AssertJsonPropertyEquals(timeTool, "type", "function");
var timeFunction = timeTool.GetProperty("function");
AssertJsonPropertyEquals(timeFunction, "name", "get_time");
AssertJsonPropertyExists(timeFunction, "description");
AssertJsonPropertyExists(timeFunction, "parameters");
// Assert - Response structure
AssertJsonPropertyExists(response, "id");
AssertJsonPropertyEquals(response, "object", "chat.completion");
AssertJsonPropertyExists(response, "created");
AssertJsonPropertyExists(response, "model");
// Assert - Response has tool_calls in choices
var choices = response.GetProperty("choices");
Assert.Equal(JsonValueKind.Array, choices.ValueKind);
Assert.True(choices.GetArrayLength() > 0);
var choice = choices[0];
AssertJsonPropertyExists(choice, "finish_reason");
AssertJsonPropertyEquals(choice, "finish_reason", anyOfValues: ["tool_calls", "stop"]);
AssertJsonPropertyExists(choice, "message");
var message = choice.GetProperty("message");
AssertJsonPropertyEquals(message, "role", "assistant");
AssertJsonPropertyExists(message, "tool_calls");
// Assert - Tool calls array structure
var toolCalls = message.GetProperty("tool_calls");
Assert.Equal(JsonValueKind.Array, toolCalls.ValueKind);
Assert.True(toolCalls.GetArrayLength() > 0);
var toolCall = toolCalls[0];
AssertJsonPropertyExists(toolCall, "id");
AssertJsonPropertyEquals(toolCall, "type", "function");
AssertJsonPropertyExists(toolCall, "function");
var callFunction = toolCall.GetProperty("function");
AssertJsonPropertyEquals(callFunction, "name", "get_weather");
AssertJsonPropertyExists(callFunction, "arguments");
// Assert - Tool call arguments are valid JSON
string arguments = callFunction.GetProperty("arguments").GetString()!;
using var argsDoc = JsonDocument.Parse(arguments);
var argsRoot = argsDoc.RootElement;
AssertJsonPropertyExists(argsRoot, "location");
AssertJsonPropertyEquals(argsRoot, "location", "San Francisco, CA");
AssertJsonPropertyEquals(argsRoot, "unit", "fahrenheit");
// Assert - Message content is null when tool_calls present
if (message.TryGetProperty("content", out var contentProp))
{
Assert.Equal(JsonValueKind.Null, contentProp.ValueKind);
}
// Assert - Usage statistics
AssertJsonPropertyExists(response, "usage");
var usage = response.GetProperty("usage");
AssertJsonPropertyExists(usage, "prompt_tokens");
AssertJsonPropertyExists(usage, "completion_tokens");
AssertJsonPropertyExists(usage, "total_tokens");
var promptTokens = usage.GetProperty("prompt_tokens").GetInt32();
var completionTokens = usage.GetProperty("completion_tokens").GetInt32();
var totalTokens = usage.GetProperty("total_tokens").GetInt32();
Assert.True(promptTokens > 0);
Assert.True(completionTokens > 0);
Assert.Equal(promptTokens + completionTokens, totalTokens);
// Assert - Service tier
AssertJsonPropertyExists(response, "service_tier");
var serviceTier = response.GetProperty("service_tier").GetString();
Assert.NotNull(serviceTier);
}
/// <summary>
/// Helper to parse chat completion chunks from SSE response.
/// </summary>