Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,60 @@ public async Task ExtraBodyOverridesFirstClassPropertyAsync()
Assert.Equal(0.0, body.GetProperty("temperature").GetDouble());
}

[Theory]
[InlineData("tools")]
[InlineData("$.tools")]
public async Task ExtraBodyToolsDoesNotDuplicatePropertyAsync(string key)
{
// Arrange
var service = new OpenAIChatCompletionService("gpt-4o", apiKey: "NOKEY", httpClient: this._httpClient);
var settings = new OpenAIPromptExecutionSettings
{
ExtraBody = new Dictionary<string, object?>
{
[key] = new object[]
{
new { type = "web_search" },
new { type = "function", function = new { name = "lookup" } },
},
},
};

// Act
await service.GetChatMessageContentsAsync(this._chatHistory, settings);

// Assert
var body = ParseRequestBody(this._messageHandlerStub.RequestContent!);
var tools = Assert.Single(body.EnumerateObject(), property => property.NameEquals("tools"));
Assert.Equal("web_search", tools.Value[0].GetProperty("type").GetString());
Assert.Equal("function", tools.Value[1].GetProperty("type").GetString());
Assert.Equal("lookup", tools.Value[1].GetProperty("function").GetProperty("name").GetString());
}

[Fact]
public async Task ExtraBodyNestedToolsPathDoesNotDuplicatePropertyAsync()
{
// Arrange
var service = new OpenAIChatCompletionService("gpt-4o", apiKey: "NOKEY", httpClient: this._httpClient);
var settings = new OpenAIPromptExecutionSettings
{
ExtraBody = new Dictionary<string, object?>
{
["$.tools[0].type"] = "web_search",
},
};

// Act
await service.GetChatMessageContentsAsync(this._chatHistory, settings);

// Assert
var body = ParseRequestBody(this._messageHandlerStub.RequestContent!);
var tools = Assert.Single(body.EnumerateObject(), property => property.NameEquals("tools"));
var tool = Assert.Single(tools.Value.EnumerateArray());
Assert.Equal("web_search", tool.GetProperty("type").GetString());
Assert.False(tool.TryGetProperty("function", out _));
}

[Fact]
public async Task ExtraBodyNestedDictionaryEmitsNestedJsonObjectAsync()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -650,6 +650,11 @@ protected override void PrepareChatOptionsForRequest(Microsoft.Extensions.AI.Cha
/// </summary>
internal static void ApplyExtraBodyEntry(ChatCompletionOptions options, string key, object? value)
{
if (TryApplyExtraBodyTools(options, key, value))
{
return;
}

string path = key.StartsWith("$.", StringComparison.Ordinal)
? key
: $"$[{JsonSerializer.Serialize(key)}]";
Expand All @@ -669,6 +674,128 @@ internal static void ApplyExtraBodyEntry(ChatCompletionOptions options, string k
#pragma warning restore SCME0001
}

private static bool TryApplyExtraBodyTools(ChatCompletionOptions options, string key, object? value)
{
if (key is "tools" or "$.tools")
{
if (value is null)
{
return false;
}

JsonElement tools = JsonSerializer.SerializeToElement(value, value.GetType());
if (tools.ValueKind != JsonValueKind.Array || tools.EnumerateArray().Any(tool => tool.ValueKind != JsonValueKind.Object))
{
return false;
}

options.Tools.Clear();
foreach (JsonElement toolElement in tools.EnumerateArray())
{
options.Tools.Add(CreatePatchedTool(toolElement));
}

return true;
}

const string ToolsIndexPrefix = "$.tools[";
if (!key.StartsWith(ToolsIndexPrefix, StringComparison.Ordinal))
{
return false;
}

int closingBracket = key.IndexOf(']', ToolsIndexPrefix.Length);
if (closingBracket < 0 ||
!int.TryParse(key[ToolsIndexPrefix.Length..closingBracket], out int toolIndex) ||
toolIndex < 0)
{
return false;
}

string toolPath = key[(closingBracket + 1)..];
if (toolPath.Length > 0 && toolPath[0] is not ('.' or '['))
{
return false;
}

while (options.Tools.Count <= toolIndex)
{
options.Tools.Add(CreateEmptyPatchedTool());
}

if (toolPath.Length == 0)
{
if (value is null)
{
return false;
}

JsonElement tool = JsonSerializer.SerializeToElement(value, value.GetType());
if (tool.ValueKind != JsonValueKind.Object)
{
return false;
}

options.Tools[toolIndex] = CreatePatchedTool(tool);
return true;
}

#pragma warning disable SCME0001 // System.ClientModel JsonPatch is for evaluation purposes only.
byte[] nestedPath = System.Text.Encoding.UTF8.GetBytes($"${toolPath}");
if (value is null)
{
options.Tools[toolIndex].Patch.SetNull(nestedPath);
}
else
{
options.Tools[toolIndex].Patch.Set(
nestedPath,
BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(value, value.GetType())));
}
#pragma warning restore SCME0001

return true;
}

private static ChatTool CreatePatchedTool(JsonElement toolElement)
{
ChatTool tool = CreateEmptyPatchedTool();

#pragma warning disable SCME0001 // System.ClientModel JsonPatch is for evaluation purposes only.
foreach (JsonProperty property in toolElement.EnumerateObject())
{
string path = property.Name is "type" or "function"
? $"$.{property.Name}"
: $"$[{JsonSerializer.Serialize(property.Name)}]";
byte[] propertyPath = System.Text.Encoding.UTF8.GetBytes(path);
if (property.Value.ValueKind == JsonValueKind.Null)
{
tool.Patch.SetNull(propertyPath);
}
else
{
tool.Patch.Set(propertyPath, BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(property.Value)));
}
}
#pragma warning restore SCME0001

return tool;
}

private static ChatTool CreateEmptyPatchedTool()
{
// ChatTool currently models function tools only. Use one as the SDK-owned carrier for the tools array,
// then remove its modeled defaults so ExtraBody remains the sole source of tool fields.
ChatTool tool = ChatTool.CreateFunctionTool("placeholder");

#pragma warning disable SCME0001 // System.ClientModel JsonPatch is for evaluation purposes only.
tool.Patch.Remove(System.Text.Encoding.UTF8.GetBytes("$.type"));
tool.Patch.Remove(System.Text.Encoding.UTF8.GetBytes("$.function"));
#pragma warning restore SCME0001

return tool;
}

/// <summary>
/// Applies all entries from <paramref name="settings"/>.<see cref="ExtraBody"/> to <paramref name="options"/>.
/// </summary>
Expand Down
Loading