mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-09-25 15:13:37 +00:00
Fixed tool calling with Groq and OpenRouter (#1003)
Some checks are pending
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Read metadata (push) Blocked by required conditions
Build and Release / Sync Flatpak repo (push) Blocked by required conditions
Build and Release / Collect Flatpak artifacts (push) Blocked by required conditions
Build and Release / Verify (push) Waiting to run
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions
Some checks are pending
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Read metadata (push) Blocked by required conditions
Build and Release / Sync Flatpak repo (push) Blocked by required conditions
Build and Release / Collect Flatpak artifacts (push) Blocked by required conditions
Build and Release / Verify (push) Waiting to run
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions
This commit is contained in:
parent
40ac215359
commit
0c585144e2
@ -1245,6 +1245,7 @@ public abstract class BaseProvider : IProvider, ISecretId
|
||||
/// <param name="requestPath">The request path, relative to the provider base URL.</param>
|
||||
/// <param name="headersAction">Optional additional headers to add.</param>
|
||||
/// <param name="mayAskForSequentialToolCalls">Whether a request which offers tools may ask for one call at a time. False for a provider which rejects the parallel_tool_calls parameter.</param>
|
||||
/// <param name="enforcesStrictToolSchemas">Whether the provider binds the model's tool calls to a strict schema. Only then are tools offered in strict mode; everywhere else a strict schema would only tell the model that every argument is required.</param>
|
||||
/// <param name="token">The cancellation token.</param>
|
||||
/// <typeparam name="TRequest">The request DTO type.</typeparam>
|
||||
/// <typeparam name="TDelta">The delta stream line type.</typeparam>
|
||||
@ -1262,6 +1263,7 @@ public abstract class BaseProvider : IProvider, ISecretId
|
||||
string requestPath = "chat/completions",
|
||||
Action<HttpRequestHeaders>? headersAction = null,
|
||||
bool mayAskForSequentialToolCalls = true,
|
||||
bool enforcesStrictToolSchemas = false,
|
||||
[EnumeratorCancellation] CancellationToken token = default)
|
||||
where TRequest : ChatCompletionAPIRequest
|
||||
where TDelta : IResponseStreamLine
|
||||
@ -1300,7 +1302,7 @@ public abstract class BaseProvider : IProvider, ISecretId
|
||||
if (runnableTools.Count > 0)
|
||||
{
|
||||
var adapter = new ChatCompletionToolCallingAdapter<TRequest>(requestFactory, systemPrompt, apiParameters,
|
||||
runnableTools.Select(x => ProviderToolAdapters.ToChatCompletionTool(x.Definition)).ToList(), mayAskForSequentialToolCalls, runnableTools,
|
||||
runnableTools.Select(x => ProviderToolAdapters.ToChatCompletionTool(x.Definition, enforcesStrictToolSchemas)).ToList(), mayAskForSequentialToolCalls, runnableTools,
|
||||
(requestDto, requestToken) => this.StreamChatCompletionRequest(requestDto, providerName, requestPath, requestedSecret, headersAction, requestToken),
|
||||
ChatCompletionSourceReader.Read<TDelta, TAnnotation>,
|
||||
this.logger);
|
||||
|
||||
@ -168,6 +168,16 @@ public sealed class ProviderOpenAI() : BaseProvider(LLMProviders.OPEN_AI, new Ur
|
||||
},
|
||||
systemPromptRole: systemPromptRole,
|
||||
requestPath: "chat/completions",
|
||||
|
||||
//
|
||||
// OpenAI binds a strict function's arguments to its schema -- read on
|
||||
// 2026-09-24 at https://developers.openai.com/api/docs/guides/function-calling.
|
||||
// Most other hosts of this API do not: Groq applies strict mode to
|
||||
// response formats only (https://console.groq.com/docs/structured-outputs,
|
||||
// same day) and merely validates a tool call afterward, so it rejects
|
||||
// every call that leaves out an argument the strict schema requires:
|
||||
//
|
||||
enforcesStrictToolSchemas: true,
|
||||
token: token))
|
||||
yield return content;
|
||||
|
||||
|
||||
@ -10,33 +10,47 @@ namespace AIStudio.Provider;
|
||||
/// <remarks>
|
||||
/// The definitions state a tool once, in plain JSON Schema. What differs per API is not only the
|
||||
/// field names but how an optional argument is expressed, which is why the OpenAI shapes convert
|
||||
/// the schema while Anthropic takes it as written.
|
||||
/// the schema while Anthropic takes it as written.<br/><br/>
|
||||
/// Strict mode is a promise of the host, not of the definition: only a host which binds the
|
||||
/// model's output to the schema keeps it. Everywhere else the model merely reads a schema that
|
||||
/// calls every argument required, and then either invents a value for an argument it meant to
|
||||
/// leave out, or the host rejects the call for lacking one. That is why the Chat Completions
|
||||
/// shape, which reaches every OpenAI-compatible host, only goes strict where the host says so.
|
||||
/// </remarks>
|
||||
public static class ProviderToolAdapters
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the nested function tool shape used by Chat Completions compatible APIs.
|
||||
/// </summary>
|
||||
public static object ToChatCompletionTool(ToolDefinition definition) => new
|
||||
/// <param name="definition">The tool to describe.</param>
|
||||
/// <param name="hostEnforcesStrict">Whether the host binds the model's tool calls to a strict schema. Only then is the tool sent in strict mode.</param>
|
||||
public static object ToChatCompletionTool(ToolDefinition definition, bool hostEnforcesStrict)
|
||||
{
|
||||
type = "function",
|
||||
function = new
|
||||
var isStrict = definition.Function.Strict && hostEnforcesStrict;
|
||||
return new
|
||||
{
|
||||
name = definition.Function.Name,
|
||||
description = definition.Function.DescriptionForLLM,
|
||||
parameters = ToOpenAIParameters(definition),
|
||||
strict = definition.Function.Strict,
|
||||
}
|
||||
};
|
||||
type = "function",
|
||||
function = new
|
||||
{
|
||||
name = definition.Function.Name,
|
||||
description = definition.Function.DescriptionForLLM,
|
||||
parameters = ToOpenAIParameters(definition, isStrict),
|
||||
strict = isStrict,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the flat function tool shape used by the OpenAI Responses API.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only OpenAI speaks this API, and it enforces strict mode, so the definition alone decides.
|
||||
/// </remarks>
|
||||
public static ResponsesFunctionTool ToResponsesTool(ToolDefinition definition) => new()
|
||||
{
|
||||
Name = definition.Function.Name,
|
||||
Description = definition.Function.DescriptionForLLM,
|
||||
Parameters = ToOpenAIParameters(definition),
|
||||
Parameters = ToOpenAIParameters(definition, definition.Function.Strict),
|
||||
Strict = definition.Function.Strict,
|
||||
};
|
||||
|
||||
@ -59,7 +73,7 @@ public static class ProviderToolAdapters
|
||||
/// <summary>
|
||||
/// The parameter schema for the OpenAI APIs, converted only when strict mode asks for it.
|
||||
/// </summary>
|
||||
private static System.Text.Json.JsonElement ToOpenAIParameters(ToolDefinition definition) => definition.Function.Strict
|
||||
private static System.Text.Json.JsonElement ToOpenAIParameters(ToolDefinition definition, bool isStrict) => isStrict
|
||||
? OpenAIStrictToolSchema.FromToolParameters(definition.Function.Parameters)
|
||||
: definition.Function.Parameters;
|
||||
}
|
||||
|
||||
@ -94,9 +94,28 @@ public sealed class WebSearchTool(IEnumerable<IWebSearchBackend> backends, WebPa
|
||||
private const string LIMIT_ARGUMENT = "limit";
|
||||
|
||||
private const string TIME_RANGE_DAY = "day";
|
||||
private const string TIME_RANGE_WEEK = "week";
|
||||
private const string TIME_RANGE_MONTH = "month";
|
||||
private const string TIME_RANGE_YEAR = "year";
|
||||
|
||||
/// <summary>
|
||||
/// The time ranges a search can be restricted to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Those which both services with a time filter, SearXNG and Tavily, understand and take as
|
||||
/// they are. Tavily documents all four. SearXNG's API documentation names no week, but its code accepts
|
||||
/// one -- read on 2026-09-24 in parse_time_range of searx/webadapter.py. A model asked about
|
||||
/// "this week" wants exactly that, and without it, it asks for a week again and again.<br/><br/>
|
||||
/// The schema offers exactly these and the reader checks against them, so the two cannot drift
|
||||
/// apart.
|
||||
/// </remarks>
|
||||
private static readonly string[] TIME_RANGES = [TIME_RANGE_DAY, TIME_RANGE_WEEK, TIME_RANGE_MONTH, TIME_RANGE_YEAR];
|
||||
|
||||
/// <summary>
|
||||
/// How much of a wrongly passed argument an error message repeats back to the model.
|
||||
/// </summary>
|
||||
private const int MAX_ARGUMENT_ECHO_LENGTH = 40;
|
||||
|
||||
public string ImplementationKey => ToolSelectionRules.WEB_SEARCH_TOOL_ID;
|
||||
|
||||
/// <inheritdoc />
|
||||
@ -118,7 +137,7 @@ public sealed class WebSearchTool(IEnumerable<IWebSearchBackend> backends, WebPa
|
||||
Parameters = ToolParameterSchemaBuilder.Create()
|
||||
.RequiredString(QUERY_ARGUMENT, "The search query.")
|
||||
.OptionalString(LANGUAGE_ARGUMENT, "Optional IETF language tag restricting the search to one language, such as 'de-DE', 'en-US', or 'all' for no restriction. Leave it out to search in the language configured for this tool. Do not pass a language name such as 'German': search engines expect the tag and silently return nothing for anything else.")
|
||||
.OptionalEnum(TIME_RANGE_ARGUMENT, "Optional time range filter for the search.", TIME_RANGE_DAY, TIME_RANGE_MONTH, TIME_RANGE_YEAR)
|
||||
.OptionalEnum(TIME_RANGE_ARGUMENT, "Optional time range filter for the search.", TIME_RANGES)
|
||||
.OptionalInteger(PAGE_ARGUMENT, "Optional search result page number starting at 1.")
|
||||
.OptionalInteger(LIMIT_ARGUMENT, $"Optional maximum number of ranked result pages to retrieve and return. The hard maximum is {MAX_RESULTS}.")
|
||||
.Build(),
|
||||
@ -482,14 +501,11 @@ public sealed class WebSearchTool(IEnumerable<IWebSearchBackend> backends, WebPa
|
||||
|
||||
public async Task<ToolExecutionResult> ExecuteAsync(JsonElement arguments, ToolExecutionContext context, CancellationToken token = default)
|
||||
{
|
||||
var query = ReadRequiredString(arguments, QUERY_ARGUMENT);
|
||||
var language = ReadOptionalString(arguments, LANGUAGE_ARGUMENT);
|
||||
var timeRange = ReadOptionalString(arguments, TIME_RANGE_ARGUMENT);
|
||||
var page = ReadOptionalPositiveInt(arguments, PAGE_ARGUMENT);
|
||||
var requestedLimit = ReadOptionalPositiveInt(arguments, LIMIT_ARGUMENT);
|
||||
|
||||
if (timeRange is not null && timeRange is not (TIME_RANGE_DAY or TIME_RANGE_MONTH or TIME_RANGE_YEAR))
|
||||
throw new ArgumentException($"Invalid time_range '{timeRange}'.");
|
||||
var query = ReadQuery(arguments);
|
||||
var language = ReadLanguage(arguments);
|
||||
var timeRange = ReadTimeRange(arguments);
|
||||
var page = ReadPage(arguments);
|
||||
var requestedLimit = ReadLimit(arguments);
|
||||
|
||||
language = string.IsNullOrWhiteSpace(language) ? context.SettingsValues.GetValueOrDefault(DEFAULT_LANGUAGE_SETTING) : language;
|
||||
var safeSearch = ReadSafeSearchPolicy(context.SettingsValues);
|
||||
@ -807,42 +823,99 @@ public sealed class WebSearchTool(IEnumerable<IWebSearchBackend> backends, WebPa
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string ReadRequiredString(JsonElement arguments, string propertyName)
|
||||
/// <summary>
|
||||
/// Reads the search query, the one argument the model always has to pass.
|
||||
/// </summary>
|
||||
internal static string ReadQuery(JsonElement arguments)
|
||||
{
|
||||
var value = ReadOptionalString(arguments, propertyName);
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
throw new ArgumentException($"Missing required argument '{propertyName}'.");
|
||||
var query = ReadOptionalString(arguments, QUERY_ARGUMENT, whenLeftOut: null);
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
throw new ArgumentException($"Missing required argument '{QUERY_ARGUMENT}'.");
|
||||
|
||||
return value;
|
||||
return query;
|
||||
}
|
||||
|
||||
private static string? ReadOptionalString(JsonElement arguments, string propertyName)
|
||||
/// <summary>
|
||||
/// Reads the language tag the model asked for, or null for the configured language.
|
||||
/// </summary>
|
||||
internal static string? ReadLanguage(JsonElement arguments) => ReadOptionalString(arguments, LANGUAGE_ARGUMENT, "to use the configured language");
|
||||
|
||||
/// <summary>
|
||||
/// Reads the time range the model asked for, or null for no restriction.
|
||||
/// </summary>
|
||||
internal static string? ReadTimeRange(JsonElement arguments)
|
||||
{
|
||||
if (!arguments.TryGetProperty(propertyName, out var value))
|
||||
if (!TryGetArgument(arguments, TIME_RANGE_ARGUMENT, out var value))
|
||||
return null;
|
||||
|
||||
return value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Null => null,
|
||||
JsonValueKind.String => value.GetString()?.Trim(),
|
||||
_ => throw new ArgumentException($"Argument '{propertyName}' must be a string."),
|
||||
};
|
||||
var timeRange = value.ValueKind is JsonValueKind.String ? value.GetString()?.Trim() : null;
|
||||
if (timeRange is null || !TIME_RANGES.Contains(timeRange, StringComparer.Ordinal))
|
||||
throw InvalidArgument(TIME_RANGE_ARGUMENT, value, $"one of {string.Join(", ", TIME_RANGES)}", "to search without a time restriction");
|
||||
|
||||
return timeRange;
|
||||
}
|
||||
|
||||
private static int? ReadOptionalPositiveInt(JsonElement arguments, string propertyName)
|
||||
/// <summary>
|
||||
/// Reads the result page the model asked for, or null for the first one.
|
||||
/// </summary>
|
||||
internal static int? ReadPage(JsonElement arguments) => ReadOptionalPositiveInt(arguments, PAGE_ARGUMENT, "to get the first page");
|
||||
|
||||
/// <summary>
|
||||
/// Reads how many results the model asked for, or null for the configured number.
|
||||
/// </summary>
|
||||
internal static int? ReadLimit(JsonElement arguments) => ReadOptionalPositiveInt(arguments, LIMIT_ARGUMENT, "to get as many results as configured");
|
||||
|
||||
/// <summary>
|
||||
/// Looks up an argument, treating null the same as leaving it out.
|
||||
/// </summary>
|
||||
private static bool TryGetArgument(JsonElement arguments, string propertyName, out JsonElement value) =>
|
||||
arguments.TryGetProperty(propertyName, out value) && value.ValueKind is not JsonValueKind.Null;
|
||||
|
||||
private static string? ReadOptionalString(JsonElement arguments, string propertyName, string? whenLeftOut)
|
||||
{
|
||||
if (!arguments.TryGetProperty(propertyName, out var value))
|
||||
if (!TryGetArgument(arguments, propertyName, out var value))
|
||||
return null;
|
||||
|
||||
if (value.ValueKind is JsonValueKind.Null)
|
||||
if (value.ValueKind is not JsonValueKind.String)
|
||||
throw InvalidArgument(propertyName, value, "a string", whenLeftOut);
|
||||
|
||||
return value.GetString()?.Trim();
|
||||
}
|
||||
|
||||
private static int? ReadOptionalPositiveInt(JsonElement arguments, string propertyName, string whenLeftOut)
|
||||
{
|
||||
if (!TryGetArgument(arguments, propertyName, out var value))
|
||||
return null;
|
||||
|
||||
if (value.ValueKind is not JsonValueKind.Number || !value.TryGetInt32(out var intValue) || intValue <= 0)
|
||||
throw new ArgumentException($"Argument '{propertyName}' must be a positive integer.");
|
||||
throw InvalidArgument(propertyName, value, "a positive integer", whenLeftOut);
|
||||
|
||||
return intValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the error a model gets for an argument it passed wrongly.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The model reads this and tries again, so it says what arrived, what would have been right,
|
||||
/// and, for an optional argument, that leaving it out is always an option. A model which
|
||||
/// believes the argument has to be there otherwise keeps trying placeholders, and every attempt
|
||||
/// costs one of the tool calls an answer may make.
|
||||
/// </remarks>
|
||||
/// <param name="propertyName">The argument.</param>
|
||||
/// <param name="value">What the model passed, as it arrived.</param>
|
||||
/// <param name="expectation">What the argument must be, completing "must be ...".</param>
|
||||
/// <param name="whenLeftOut">What happens without the argument, completing "Leave it out ...", or null for a required one.</param>
|
||||
private static ArgumentException InvalidArgument(string propertyName, JsonElement value, string expectation, string? whenLeftOut)
|
||||
{
|
||||
var receivedValue = value.GetRawText();
|
||||
if (receivedValue.Length > MAX_ARGUMENT_ECHO_LENGTH)
|
||||
receivedValue = $"{receivedValue[..MAX_ARGUMENT_ECHO_LENGTH]}...";
|
||||
|
||||
var message = $"Argument '{propertyName}' must be {expectation}, but was {receivedValue}.";
|
||||
return new ArgumentException(whenLeftOut is null ? message : $"{message} Leave it out {whenLeftOut}.");
|
||||
}
|
||||
|
||||
private static string FormatQueryForLog(string query)
|
||||
{
|
||||
var singleLineQuery = query
|
||||
|
||||
@ -8,6 +8,14 @@ public sealed class ToolFunctionDefinition
|
||||
|
||||
public string DescriptionForLLM { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this tool may be offered in strict mode.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The host has a say as well: a tool goes strict only where the host binds the model's calls
|
||||
/// to the schema, see ProviderToolAdapters. Setting this to false keeps a tool out of strict
|
||||
/// mode everywhere, for a schema which strict mode cannot express.
|
||||
/// </remarks>
|
||||
public bool Strict { get; init; } = true;
|
||||
|
||||
public JsonElement Parameters { get; init; }
|
||||
|
||||
107
app/Tests/Provider/ToolCalling/OpenAIStrictToolSchemaTests.cs
Normal file
107
app/Tests/Provider/ToolCalling/OpenAIStrictToolSchemaTests.cs
Normal file
@ -0,0 +1,107 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using AIStudio.Provider.OpenAI;
|
||||
using AIStudio.Tools.ToolCallingSystem;
|
||||
|
||||
namespace AIStudio.Tests.Provider.ToolCalling;
|
||||
|
||||
/// <summary>
|
||||
/// Checks how a tool's parameter schema is translated into the form OpenAI's strict mode requires.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Strict mode wants every argument required and says "may be left out" by allowing null instead.
|
||||
/// The translation has to keep what the schema means: an argument the tool can do without stays
|
||||
/// one the model can leave empty, and an argument the tool needs stays exactly as it was.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class OpenAIStrictToolSchemaTests
|
||||
{
|
||||
[Test]
|
||||
public void AnOptionalArgumentMayBeNullInstead()
|
||||
{
|
||||
var properties = Converted(ToolParameterSchemaBuilder.Create()
|
||||
.RequiredString("query", "The search query.")
|
||||
.OptionalString("language", "The language.")
|
||||
.OptionalInteger("page", "The page."))["properties"]!;
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(Types(properties["language"]!), Is.EqualTo(new[] { "string", "null" }));
|
||||
Assert.That(Types(properties["page"]!), Is.EqualTo(new[] { "integer", "null" }));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AnOptionalChoiceOffersNullAmongItsValues()
|
||||
{
|
||||
var timeRange = Converted(ToolParameterSchemaBuilder.Create()
|
||||
.RequiredString("query", "The search query.")
|
||||
.OptionalEnum("time_range", "The time range.", "day", "month", "year"))["properties"]!["time_range"]!;
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(Types(timeRange), Is.EqualTo(new[] { "string", "null" }));
|
||||
Assert.That(timeRange["enum"]!.AsArray().Select(value => value?.GetValue<string>()), Is.EqualTo(new[] { null, "day", "month", "year" }));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ARequiredArgumentStaysAsItIs()
|
||||
{
|
||||
var query = Converted(ToolParameterSchemaBuilder.Create()
|
||||
.RequiredString("query", "The search query.")
|
||||
.OptionalString("language", "The language."))["properties"]!["query"]!;
|
||||
|
||||
Assert.That(query["type"]!.GetValue<string>(), Is.EqualTo("string"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EveryArgumentIsRequiredInTheOrderOfTheProperties()
|
||||
{
|
||||
//
|
||||
// The order has to be stable across requests, because prompt caching depends on it. The
|
||||
// optional argument comes first here, so an order taken from the old required list would
|
||||
// show.
|
||||
//
|
||||
var schema = Converted(ToolParameterSchemaBuilder.Create()
|
||||
.OptionalString("language", "The language.")
|
||||
.RequiredString("query", "The search query."));
|
||||
|
||||
Assert.That(schema["required"]!.AsArray().Select(name => name!.GetValue<string>()), Is.EqualTo(new[] { "language", "query" }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ExtraArgumentsStayRefused()
|
||||
{
|
||||
var schema = Converted(ToolParameterSchemaBuilder.Create()
|
||||
.RequiredString("query", "The search query.")
|
||||
.OptionalString("language", "The language."));
|
||||
|
||||
Assert.That(schema["additionalProperties"]!.GetValue<bool>(), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ASchemaWithoutOptionalArgumentsIsLeftUntouched()
|
||||
{
|
||||
var parameters = ToolParameterSchemaBuilder.Create()
|
||||
.RequiredString("url", "The address of the page.")
|
||||
.Build();
|
||||
|
||||
var converted = OpenAIStrictToolSchema.FromToolParameters(parameters);
|
||||
Assert.That(JsonNode.DeepEquals(JsonNode.Parse(converted.GetRawText()), JsonNode.Parse(parameters.GetRawText())), Is.True);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the schema and returns it the way strict mode receives it.
|
||||
/// </summary>
|
||||
private static JsonNode Converted(ToolParameterSchemaBuilder builder) => JsonNode.Parse(OpenAIStrictToolSchema.FromToolParameters(builder.Build()).GetRawText())!;
|
||||
|
||||
/// <summary>
|
||||
/// Reads the types a property allows, whether it names one or several.
|
||||
/// </summary>
|
||||
private static string[] Types(JsonNode property) => property["type"] switch
|
||||
{
|
||||
JsonArray types => types.Select(type => type!.GetValue<string>()).ToArray(),
|
||||
var type => [type!.GetValue<string>()],
|
||||
};
|
||||
}
|
||||
85
app/Tests/Provider/ToolCalling/ProviderToolAdaptersTests.cs
Normal file
85
app/Tests/Provider/ToolCalling/ProviderToolAdaptersTests.cs
Normal file
@ -0,0 +1,85 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using AIStudio.Provider;
|
||||
using AIStudio.Tools.ToolCallingSystem;
|
||||
|
||||
namespace AIStudio.Tests.Provider.ToolCalling;
|
||||
|
||||
/// <summary>
|
||||
/// Checks when a tool reaches a Chat Completions host in strict mode, and when as written.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Strict mode only holds where the host binds the model's calls to the schema. Anywhere else, the
|
||||
/// model just reads a schema which calls every argument required: Groq then rejects each call that
|
||||
/// leaves one out, and other models fill the gap with a placeholder the tool has to refuse. Such a
|
||||
/// host therefore gets the schema the way the tool wrote it.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class ProviderToolAdaptersTests
|
||||
{
|
||||
[Test]
|
||||
public void AHostWhichDoesNotEnforceStrictModeGetsTheSchemaAsWritten()
|
||||
{
|
||||
var definition = WebSearchLike();
|
||||
var function = Sent(definition, hostEnforcesStrict: false);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(function["strict"]!.GetValue<bool>(), Is.False);
|
||||
Assert.That(JsonNode.DeepEquals(function["parameters"], JsonNode.Parse(definition.Function.Parameters.GetRawText())), Is.True);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AHostWhichEnforcesStrictModeGetsTheConvertedSchema()
|
||||
{
|
||||
var function = Sent(WebSearchLike(), hostEnforcesStrict: true);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(function["strict"]!.GetValue<bool>(), Is.True);
|
||||
Assert.That(function["parameters"]!["required"]!.AsArray().Select(name => name!.GetValue<string>()), Is.EqualTo(new[] { "query", "page" }));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AToolWhichOptsOutStaysOutOfStrictModeEverywhere()
|
||||
{
|
||||
var definition = WebSearchLike(isStrict: false);
|
||||
var function = Sent(definition, hostEnforcesStrict: true);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(function["strict"]!.GetValue<bool>(), Is.False);
|
||||
Assert.That(JsonNode.DeepEquals(function["parameters"], JsonNode.Parse(definition.Function.Parameters.GetRawText())), Is.True);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A tool with one required and one optional argument, the shape the web search has.
|
||||
/// </summary>
|
||||
private static ToolDefinition WebSearchLike(bool isStrict = true) => new()
|
||||
{
|
||||
Id = "web_search",
|
||||
Function = new()
|
||||
{
|
||||
Name = "web_search",
|
||||
DescriptionForLLM = "Search the web.",
|
||||
Strict = isStrict,
|
||||
Parameters = ToolParameterSchemaBuilder.Create()
|
||||
.RequiredString("query", "The search query.")
|
||||
.OptionalInteger("page", "The page, starting at 1.")
|
||||
.Build(),
|
||||
},
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Returns the function object of the tool as it goes over the wire.
|
||||
/// </summary>
|
||||
private static JsonNode Sent(ToolDefinition definition, bool hostEnforcesStrict)
|
||||
{
|
||||
var tool = ProviderToolAdapters.ToChatCompletionTool(definition, hostEnforcesStrict);
|
||||
return JsonNode.Parse(JsonSerializer.Serialize(tool, ProviderJsonOptions.OPTIONS))!["function"]!;
|
||||
}
|
||||
}
|
||||
170
app/Tests/Tools/ToolCalling/WebSearchToolArgumentTests.cs
Normal file
170
app/Tests/Tools/ToolCalling/WebSearchToolArgumentTests.cs
Normal file
@ -0,0 +1,170 @@
|
||||
using System.Text.Json;
|
||||
|
||||
using AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.WebSearch;
|
||||
|
||||
namespace AIStudio.Tests.Tools.ToolCalling;
|
||||
|
||||
/// <summary>
|
||||
/// Checks how the web search reads the arguments a model passes, and what it says about wrong ones.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A wrong argument is refused rather than guessed at: a placeholder such as 0 is not a page, and
|
||||
/// quietly reading it as "no page" would do something the model did not ask for. What makes the
|
||||
/// refusal work is its message. The model reads it and tries again, so it has to say what arrived,
|
||||
/// what would have been right, and that an optional argument may simply be left out. A model
|
||||
/// which believes it has to pass one otherwise keeps trying placeholders, and every attempt costs
|
||||
/// one of the tool calls an answer may make.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class WebSearchToolArgumentTests
|
||||
{
|
||||
[Test]
|
||||
public void AnArgumentLeftOutIsNotSet()
|
||||
{
|
||||
var arguments = Arguments("""{"query":"weather"}""");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(WebSearchTool.ReadLanguage(arguments), Is.Null);
|
||||
Assert.That(WebSearchTool.ReadTimeRange(arguments), Is.Null);
|
||||
Assert.That(WebSearchTool.ReadPage(arguments), Is.Null);
|
||||
Assert.That(WebSearchTool.ReadLimit(arguments), Is.Null);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ANullArgumentIsTheSameAsOneLeftOut()
|
||||
{
|
||||
var arguments = Arguments("""{"query":"weather","language":null,"time_range":null,"page":null,"limit":null}""");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(WebSearchTool.ReadLanguage(arguments), Is.Null);
|
||||
Assert.That(WebSearchTool.ReadTimeRange(arguments), Is.Null);
|
||||
Assert.That(WebSearchTool.ReadPage(arguments), Is.Null);
|
||||
Assert.That(WebSearchTool.ReadLimit(arguments), Is.Null);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ValidArgumentsComeThrough()
|
||||
{
|
||||
var arguments = Arguments("""{"query":" weather ","language":"de-DE","time_range":"day","page":2,"limit":7}""");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(WebSearchTool.ReadQuery(arguments), Is.EqualTo("weather"));
|
||||
Assert.That(WebSearchTool.ReadLanguage(arguments), Is.EqualTo("de-DE"));
|
||||
Assert.That(WebSearchTool.ReadTimeRange(arguments), Is.EqualTo("day"));
|
||||
Assert.That(WebSearchTool.ReadPage(arguments), Is.EqualTo(2));
|
||||
Assert.That(WebSearchTool.ReadLimit(arguments), Is.EqualTo(7));
|
||||
});
|
||||
}
|
||||
|
||||
[TestCase("0")]
|
||||
[TestCase("-1")]
|
||||
[TestCase("2.5")]
|
||||
[TestCase("\"5\"")]
|
||||
[TestCase("true")]
|
||||
public void AWrongPageIsRefusedWithWhatArrived(string value)
|
||||
{
|
||||
var message = Refusal(() => WebSearchTool.ReadPage(Arguments($$"""{"query":"weather","page":{{value}}}""")));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(message, Does.Contain("'page'").And.Contain("a positive integer"));
|
||||
Assert.That(message, Does.Contain($"but was {value}."), "Without the value, the model cannot tell which of its arguments the tool means.");
|
||||
Assert.That(message, Does.Contain("Leave it out"), "The way out a model needs when it believes the argument is required.");
|
||||
});
|
||||
}
|
||||
|
||||
[TestCase("0")]
|
||||
[TestCase("-1")]
|
||||
[TestCase("\"5\"")]
|
||||
public void AWrongLimitIsRefusedWithWhatArrived(string value)
|
||||
{
|
||||
var message = Refusal(() => WebSearchTool.ReadLimit(Arguments($$"""{"query":"weather","limit":{{value}}}""")));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(message, Does.Contain("'limit'").And.Contain("a positive integer"));
|
||||
Assert.That(message, Does.Contain($"but was {value}."));
|
||||
Assert.That(message, Does.Contain("Leave it out"));
|
||||
});
|
||||
}
|
||||
|
||||
[TestCase("day")]
|
||||
[TestCase("week")]
|
||||
[TestCase("month")]
|
||||
[TestCase("year")]
|
||||
public void EveryOfferedTimeRangeIsAccepted(string timeRange)
|
||||
{
|
||||
//
|
||||
// The week is the one a model asks for when the user says "this week". Both services with
|
||||
// a time filter understand it, and refusing it only made the model try it again and again.
|
||||
//
|
||||
Assert.That(WebSearchTool.ReadTimeRange(Arguments($$"""{"query":"weather","time_range":"{{timeRange}}"}""")), Is.EqualTo(timeRange));
|
||||
}
|
||||
|
||||
[TestCase("\"\"")]
|
||||
[TestCase("\"Day\"")]
|
||||
[TestCase("\"decade\"")]
|
||||
[TestCase("5")]
|
||||
public void AWrongTimeRangeIsRefusedWithTheValuesThatWouldDo(string value)
|
||||
{
|
||||
var message = Refusal(() => WebSearchTool.ReadTimeRange(Arguments($$"""{"query":"weather","time_range":{{value}}}""")));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(message, Does.Contain("'time_range'").And.Contain("one of day, week, month, year"));
|
||||
Assert.That(message, Does.Contain($"but was {value}."));
|
||||
Assert.That(message, Does.Contain("Leave it out"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ALanguageWhichIsNoStringIsRefused()
|
||||
{
|
||||
var message = Refusal(() => WebSearchTool.ReadLanguage(Arguments("""{"query":"weather","language":5}""")));
|
||||
Assert.That(message, Does.Contain("'language'").And.Contain("but was 5.").And.Contain("Leave it out"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AMissingQueryIsRefused()
|
||||
{
|
||||
var message = Refusal(() => WebSearchTool.ReadQuery(Arguments("""{"page":1}""")));
|
||||
Assert.That(message, Does.Contain("Missing required argument 'query'"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AQueryWhichIsNoStringIsRefusedWithoutOfferingToLeaveItOut()
|
||||
{
|
||||
var message = Refusal(() => WebSearchTool.ReadQuery(Arguments("""{"query":42}""")));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(message, Does.Contain("'query'").And.Contain("but was 42."));
|
||||
Assert.That(message, Does.Not.Contain("Leave it out"), "The query is required, so leaving it out is no way out.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ALongValueIsShortenedInTheMessage()
|
||||
{
|
||||
var longValue = new string('x', 500);
|
||||
var message = Refusal(() => WebSearchTool.ReadTimeRange(Arguments($$"""{"query":"weather","time_range":"{{longValue}}"}""")));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(message, Does.Not.Contain(longValue), "The model sent the value itself; repeating all of it back only costs tokens.");
|
||||
Assert.That(message, Does.Contain("..."));
|
||||
});
|
||||
}
|
||||
|
||||
private static JsonElement Arguments(string json) => JsonSerializer.Deserialize<JsonElement>(json);
|
||||
|
||||
/// <summary>
|
||||
/// Runs a reader which has to refuse its argument and returns what it said.
|
||||
/// </summary>
|
||||
private static string Refusal(TestDelegate read) => Assert.Throws<ArgumentException>(read)!.Message;
|
||||
}
|
||||
@ -24,7 +24,9 @@ Adding a provider API means writing an `IToolCallingProviderAdapter`, not anothe
|
||||
|
||||
`Function.Parameters` is plain JSON Schema: an optional argument is simply absent from `required`. That is what `ToolParameterSchemaBuilder` writes, and Anthropic reads it as written.
|
||||
|
||||
OpenAI's strict mode wants it differently. It insists that **every** property appear in `required`, so an argument that may be left out has to say so by allowing null instead — `"type": ["string", "null"]`, plus `null` among its enum values where it has any. `OpenAIStrictToolSchema.FromToolParameters` therefore converts on the way out, for both OpenAI shapes and only where `Strict` is set. Nothing is lost, because a tool treats an absent argument and a null one the same way.
|
||||
OpenAI's strict mode wants it differently. It insists that **every** property appear in `required`, so an argument that may be left out has to say so by allowing null instead — `"type": ["string", "null"]`, plus `null` among its enum values where it has any. `OpenAIStrictToolSchema.FromToolParameters` therefore converts on the way out, but only where strict mode is kept. Nothing is lost there, because a tool treats an absent argument and a null one the same way.
|
||||
|
||||
Strict mode is a promise of the host, not of the definition: only a host which binds the model's output to the schema keeps it. OpenAI does so in both of its APIs, and Anthropic does so without needing any conversion. Anywhere else, the converted schema would only tell the model that every argument is required. Groq, which validates a tool call against the schema without binding the model to it, then rejects each call that leaves an optional argument out, and other models fill the gap with placeholders such as `0` that the tool has to refuse. Every other Chat Completions host therefore gets the schema as written, with `strict: false`. A host which does bind tool calls says so by passing `enforcesStrictToolSchemas: true` to `StreamOpenAICompatibleChatCompletion`, as `ProviderOpenAI` does, next to the page that documents it. `ToolFunctionDefinition.Strict` works the other way round: set to false, it keeps a tool out of strict mode everywhere, for a schema strict mode cannot express.
|
||||
|
||||
So the canonical schema is provider-neutral, and the provider that wants something else translates away from it in its own adapter. That is where the next such conversion belongs too — not in the definition.
|
||||
|
||||
@ -34,7 +36,9 @@ Tool result handling also differs by API, and this is what the adapters exist fo
|
||||
- **Responses** returns `function_call` output items and receives results as `function_call_output` input items correlated by `call_id`. There the ID comes from the provider, so a call without one cannot be answered at all and ends the conversation. The whole output of a round has to be sent back for the next one, reasoning items included.
|
||||
- **Anthropic** works in content blocks: the model's turn is one assistant message whose blocks may mix `text`, `thinking`, and `tool_use`, and it has to be returned unchanged — thinking blocks in particular. All results of a round belong in a **single** user message as `tool_result` blocks; splitting them across several messages teaches the model to stop asking for more than one tool at a time. It is also the only one of the three with an error flag on a result (`is_error`), which the harness sets for failed and blocked calls.
|
||||
|
||||
AI Studio currently executes local tool calls sequentially. Therefore, Chat Completions requests with tools always set `parallel_tool_calls` to `false`, limiting each model response to at most one tool call. Requests without tools omit the parameter, and additional API parameters cannot override this behavior. Models can still request additional tools across subsequent responses.
|
||||
AI Studio currently executes local tool calls sequentially. Therefore, Chat Completions requests with tools set `parallel_tool_calls` to `false`, limiting each model response to at most one tool call. Requests without tools omit the parameter, and additional API parameters cannot override this behavior. Models can still request additional tools across subsequent responses.
|
||||
|
||||
The exception is a provider which rejects the parameter. Hugging Face answers it with a bad request, so its provider passes `mayAskForSequentialToolCalls: false` to `StreamOpenAICompatibleChatCompletion`, and its requests omit the parameter even when they offer tools. Its models may then ask for several calls in one response, which the loop works through one by one, checking the limits per call. Not every provider honors the parameter either — OpenRouter let Qwen ask for two calls at once — and such a response is handled the same way.
|
||||
|
||||
The OpenAI Responses API may continue to return multiple function calls in one response. AI Studio processes those calls sequentially as well; concurrent execution of separate local tool calls is not currently implemented. This does not restrict concurrency used internally by an individual tool.
|
||||
|
||||
@ -58,7 +62,7 @@ When a tool returns data that future messages must only send to providers at or
|
||||
|
||||
## Security
|
||||
|
||||
Treat model-provided tool arguments as untrusted input.
|
||||
Treat model-provided tool arguments as untrusted input. Refuse a wrong one rather than guessing what it meant: a placeholder such as `0` is not a page, and reading it as "no page" does something the model did not ask for. The model reads the refusal and tries again, so the message has to name the argument and the value that arrived, say what would be valid, and, for an optional argument, that leaving it out is always possible. `WebSearchTool` shows the pattern.
|
||||
|
||||
For tools that perform network requests:
|
||||
|
||||
@ -107,7 +111,7 @@ Every successfully retrieved page with readable content is also returned as a st
|
||||
- Put every argument and setting name in a constant that the schema and the reading code share.
|
||||
- Set `MinimumProviderConfidence` to what the tool actually exposes.
|
||||
- Mark a setting the tool cannot work without as `Required`, rather than saying so in its description.
|
||||
- Validate settings and model arguments.
|
||||
- Validate settings and model arguments, and refuse a wrong argument with a message the model can correct itself from.
|
||||
- Filter content fetched from outside AI Studio for prompt injections, and declare `ReturnsUntrustedExternalContent`.
|
||||
- Protect secrets and sensitive trace arguments.
|
||||
- Add provider-confidence checks when tool output may contain sensitive data.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user