Added tool calling support (#731)

Co-authored-by: krut_ni <nils.kruthoff@dlr.de>
Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-09-04 15:48:07 +02:00
1 parent b00c3f9ab3
commit 4d8d30e15e
266 files changed
+11186 -740

No files matched your search

@@ -0,0 +1,13 @@
using System.Text.Json;
namespace AIStudio.Provider.Anthropic;
/// <summary>
/// One turn of the model, handed back unchanged so the conversation can continue.
/// </summary>
/// <remarks>
/// The content blocks are kept as raw JSON on purpose. A turn can carry text, tool uses, and
/// thinking blocks, and the thinking blocks have to return exactly as they arrived — reading and
/// rebuilding them would risk changing them.
/// </remarks>
public sealed record AnthropicMessage(IList<JsonElement> Content, string Role = "assistant") : IMessage<IList<JsonElement>>;
@@ -0,0 +1,48 @@
using System.Text.Json;
namespace AIStudio.Provider.Anthropic;
/// <summary>
/// One non-streamed answer of the Anthropic messages API.
/// </summary>
public sealed record AnthropicResponse
{
public string StopReason { get; init; } = string.Empty;
public IList<JsonElement> Content { get; init; } = [];
/// <summary>
/// The tool calls the model asked for.
/// </summary>
/// <remarks>
/// A block without an ID or a name cannot be answered and is dropped here, so the harness
/// sees a well-formed list. Anthropic supplies both for every real tool use.
/// </remarks>
public IReadOnlyList<AnthropicToolUse> GetToolUses() => this.Content
.Where(x => ReadString(x, "type").Equals("tool_use", StringComparison.Ordinal))
.Select(x => new AnthropicToolUse
{
Id = ReadString(x, "id"),
Name = ReadString(x, "name"),
Input = x.TryGetProperty("input", out var input) ? input : default,
})
.Where(x => !string.IsNullOrWhiteSpace(x.Id) && !string.IsNullOrWhiteSpace(x.Name))
.ToList();
/// <summary>
/// The text the model wrote, with its blocks joined.
/// </summary>
public string GetTextOutput() => string.Concat(this.Content
.Where(x => ReadString(x, "type").Equals("text", StringComparison.Ordinal))
.Select(x => ReadString(x, "text")));
private static string ReadString(JsonElement item, string propertyName)
{
if (item.ValueKind is not JsonValueKind.Object ||
!item.TryGetProperty(propertyName, out var property) ||
property.ValueKind is not JsonValueKind.String)
return string.Empty;
return property.GetString() ?? string.Empty;
}
}
@@ -0,0 +1,21 @@
using System.Text.Json;
namespace AIStudio.Provider.Anthropic;
/// <summary>
/// A tool as the Anthropic messages API expects it.
/// </summary>
/// <remarks>
/// Anthropic names the schema field input schema, where the chat completions and responses APIs
/// call it parameters. The description is the plain one, without their nesting.
/// </remarks>
public sealed record AnthropicTool
{
public string Name { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
public bool Strict { get; init; }
public JsonElement InputSchema { get; init; }
}
@@ -0,0 +1,88 @@
using AIStudio.Tools.ToolCallingSystem;
using AIStudio.Tools.ToolCallingSystem.Harness;
namespace AIStudio.Provider.Anthropic;
/// <summary>
/// Speaks the Anthropic messages wire format for the tool calling loop.
/// </summary>
/// <remarks>
/// Anthropic works in content blocks rather than in separate message kinds: the model's turn is
/// one assistant message whose blocks may mix text, thinking, and tool uses, and the results go
/// back as tool result blocks inside a single user message. That difference is what made this
/// provider hard to support before the loop and the wire format were separated — it is now the
/// only thing this class is about.
/// </remarks>
public sealed class AnthropicToolCallingAdapter(Model chatModel, IList<IMessageBase> baseMessages, string systemPrompt, int maxTokens,
IDictionary<string, object> apiParameters, IReadOnlyList<(ToolDefinition Definition, IToolImplementation Implementation)> runnableTools,
Func<ChatRequest, CancellationToken, Task<AnthropicResponse?>> executeRequestAsync) : IToolCallingProviderAdapter
{
private readonly List<IMessageBase> internalMessages = [];
private readonly List<AnthropicToolResultContent> pendingToolResults = [];
private readonly List<AnthropicTool> tools = runnableTools.Select(x => ProviderToolAdapters.ToAnthropicTool(x.Definition)).ToList();
private AnthropicResponse? lastResponse;
/// <inheritdoc />
public async Task<ToolCallingRound?> ExecuteRoundAsync(string? finalResponseInstruction, bool includeTools, CancellationToken token = default)
{
//
// The results of the previous round are flushed here rather than when they were recorded:
// they all belong in one user message, and only now is it certain that no more are coming.
//
if (this.pendingToolResults.Count > 0)
{
this.internalMessages.Add(new AnthropicToolResultMessage([..this.pendingToolResults]));
this.pendingToolResults.Clear();
}
var response = await executeRequestAsync(new ChatRequest
{
Model = chatModel.Id,
Messages = [..baseMessages, ..this.internalMessages],
System = finalResponseInstruction is null
? systemPrompt
: $"{systemPrompt}{Environment.NewLine}{Environment.NewLine}{finalResponseInstruction}",
MaxTokens = maxTokens,
Stream = false,
Tools = includeTools && this.tools.Count > 0 ? this.tools : null,
AdditionalApiParameters = apiParameters,
}, token);
if (response is null)
return null;
this.lastResponse = response;
return new ToolCallingRound(
response.GetTextOutput(),
response.GetToolUses()
.Select(toolUse => new ToolCallingRequestedCall(
toolUse.Id,
toolUse.Name,
toolUse.Arguments,
ToolExecutor.IsValidArgumentsJson(toolUse.Arguments)))
.ToList(),
[]);
}
/// <inheritdoc />
public void RecordAssistantTurn()
{
if (this.lastResponse is null)
return;
//
// The blocks go back exactly as they arrived. Thinking blocks in particular have to be
// returned unchanged for the model to continue from them.
//
this.internalMessages.Add(new AnthropicMessage([..this.lastResponse.Content]));
}
/// <inheritdoc />
public void RecordToolResult(string callId, string content, bool isError = false) => this.pendingToolResults.Add(new AnthropicToolResultContent
{
ToolUseId = callId,
Content = content,
IsError = isError,
});
}
@@ -0,0 +1,22 @@
using System.Text.Json.Serialization;
namespace AIStudio.Provider.Anthropic;
public sealed record AnthropicToolResultContent
{
public string Type { get; init; } = "tool_result";
public string ToolUseId { get; init; } = string.Empty;
public string Content { get; init; } = string.Empty;
/// <summary>
/// Whether the tool failed rather than returning a result.
/// </summary>
/// <remarks>
/// Only sent when true: Anthropic reads its absence as success, and this way a successful
/// result stays byte-identical to what earlier versions sent.
/// </remarks>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public bool IsError { get; init; }
}
@@ -0,0 +1,10 @@
namespace AIStudio.Provider.Anthropic;
/// <summary>
/// The results of the tools the model asked for, as one user turn.
/// </summary>
/// <remarks>
/// All results of one turn belong in a single message. Splitting them across several messages
/// teaches the model to stop asking for more than one tool at a time.
/// </remarks>
public sealed record AnthropicToolResultMessage(IList<AnthropicToolResultContent> Content, string Role = "user") : IMessage<IList<AnthropicToolResultContent>>;
@@ -0,0 +1,17 @@
using System.Text.Json;
namespace AIStudio.Provider.Anthropic;
public sealed record AnthropicToolUse
{
public string Id { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
public JsonElement Input { get; init; }
/// <summary>
/// The arguments as JSON text, which is what the tool executor works with.
/// </summary>
public string Arguments => this.Input.ValueKind is JsonValueKind.Undefined ? "{}" : this.Input.GetRawText();
}
@@ -18,7 +18,17 @@ public readonly record struct ChatRequest(
string System
)
{
/// <summary>
/// The tools the model may call, or null when it should answer without them.
/// </summary>
/// <remarks>
/// Omitted from the request when null: sending an empty list is not the same as sending no
/// tools at all, and the final round of a tool conversation has to offer none.
/// </remarks>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IList<AnthropicTool>? Tools { get; init; }
// Attention: The "required" modifier is not supported for [JsonExtensionData].
[JsonExtensionData]
public IDictionary<string, object> AdditionalApiParameters { get; init; } = new Dictionary<string, object>();
}
}
@@ -0,0 +1,9 @@
// ReSharper disable NotAccessedPositionalProperty.Global
namespace AIStudio.Provider.Anthropic;
/// <summary>
/// The delta object of a response line.
/// </summary>
/// <param name="Type">The type of the delta.</param>
/// <param name="Text">The text of the delta.</param>
public readonly record struct Delta(string Type, string Text);
@@ -5,13 +5,15 @@ using System.Text.Json;
using AIStudio.Chat;
using AIStudio.Provider.OpenAI;
using AIStudio.Settings;
using AIStudio.Tools.Rust;
using AIStudio.Tools.ToolCallingSystem;
using AIStudio.Tools.ToolCallingSystem.Harness;
namespace AIStudio.Provider.Anthropic;
public sealed class ProviderAnthropic() : BaseProvider(LLMProviders.ANTHROPIC, new Uri("https://api.anthropic.com/v1/"), ExternalHttpTrustPolicy.SYSTEM_TRUST_ONLY, LOGGER)
{
private static readonly ILogger<ProviderAnthropic> LOGGER = Program.LOGGER_FACTORY.CreateLogger<ProviderAnthropic>();
#region Implementation of IProvider
/// <inheritdoc />
@@ -70,18 +72,59 @@ public sealed class ProviderAnthropic() : BaseProvider(LLMProviders.ANTHROPIC, n
}
}
);
//
// Prepare the tools we want to use. When the model may call one, the conversation runs
// through the harness instead of being streamed straight away: tool rounds are not
// streamed, only the final answer is.
//
var toolRegistry = Program.SERVICE_PROVIDER.GetService<ToolRegistry>();
var toolExecutor = Program.SERVICE_PROVIDER.GetService<ToolExecutor>();
var currentAssistantContent = chatThread.Blocks.LastOrDefault(x => x.Role is ChatRole.AI)?.Content as ContentText;
currentAssistantContent?.ToolInvocations.Clear();
var providerSettings = this.CreateSettingsProvider(chatModel);
var runnableTools = toolRegistry is null
? []
: await toolRegistry.GetRunnableToolsAsync(providerSettings, chatThread.RuntimeComponent, chatThread.RuntimeSelectedToolIds,
this.Provider.GetConfidence(settingsManager).Level, chatThread.MayRunTools(settingsManager));
var systemPrompt = chatThread.PrepareSystemPrompt(settingsManager, runnableTools.Select(x => x.Definition));
if (toolExecutor is not null && runnableTools.Count > 0)
{
var adapter = new AnthropicToolCallingAdapter(chatModel, [..messages], systemPrompt, maxTokens, apiParameters, runnableTools,
(requestDto, requestToken) => this.ExecuteMessagesRequest(requestDto, requestedSecret, requestToken));
var loop = Program.SERVICE_PROVIDER.GetRequiredService<IToolCallingLoop>();
var loopContext = new ToolCallingLoopContext
{
ChatThread = chatThread,
RunnableTools = runnableTools,
ToolExecutor = toolExecutor,
Provider = this,
CurrentAssistantContent = currentAssistantContent,
ProviderInstanceName = this.InstanceName,
ProviderType = this.Provider,
ModelId = chatModel.Id,
};
await foreach (var content in loop.RunAsync(adapter, loopContext, token))
yield return content;
yield break;
}
// Prepare the Anthropic HTTP chat request:
var chatRequest = JsonSerializer.Serialize(new ChatRequest
{
Model = chatModel.Id,
// Build the messages:
Messages = [..messages],
System = chatThread.PrepareSystemPrompt(settingsManager),
System = systemPrompt,
MaxTokens = maxTokens,
// Right now, we only support streaming completions:
Stream = true,
AdditionalApiParameters = apiParameters
@@ -107,6 +150,33 @@ public sealed class ProviderAnthropic() : BaseProvider(LLMProviders.ANTHROPIC, n
yield return content;
}
/// <summary>
/// Runs one non-streamed messages request, as the tool rounds need it.
/// </summary>
/// <remarks>
/// Tool rounds are not streamed: the whole answer has to be there before its tool calls can
/// be executed. Only the final answer reaches the user through the streaming path.
/// </remarks>
/// <returns>The answer, or null when the request failed and the user was already told.</returns>
private async Task<AnthropicResponse?> ExecuteMessagesRequest(ChatRequest requestDto, RequestedSecret requestedSecret, CancellationToken token)
{
using var request = new HttpRequestMessage(HttpMethod.Post, "messages");
request.Headers.Add("x-api-key", await requestedSecret.Secret.Decrypt(Program.ENCRYPTION));
request.Headers.Add("anthropic-version", "2023-06-01");
request.Content = new StringContent(JsonSerializer.Serialize(requestDto, JSON_SERIALIZER_OPTIONS), Encoding.UTF8, "application/json");
using var response = await this.HttpClient.SendAsync(request, token);
if (!response.IsSuccessStatusCode)
{
var responseBody = await response.Content.ReadAsStringAsync(token);
LOGGER.LogError("Tool calling messages request failed with status code {ResponseStatusCode} and body: '{ResponseBody}'.", response.StatusCode, responseBody);
await ToolCallingMessages.SendToolCallingRequestFailedAsync((int)response.StatusCode);
return null;
}
return await response.Content.ReadFromJsonAsync<AnthropicResponse>(JSON_SERIALIZER_OPTIONS, token);
}
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
/// <inheritdoc />
public override async IAsyncEnumerable<ImageURL> StreamImageCompletion(Model imageModel, string promptPositive, string promptNegative = FilterOperator.String.Empty, ImageURL referenceImageURL = default, [EnumeratorCancellation] CancellationToken token = default)
@@ -140,7 +210,7 @@ public sealed class ProviderAnthropic() : BaseProvider(LLMProviders.ANTHROPIC, n
new Model("claude-3-opus-latest", "Claude 3 Opus (Latest)"),
};
var result = await this.LoadModels(SecretStoreType.LLM_PROVIDER, token, apiKeyProvisional);
var result = await this.LoadModels(SecretStoreType.LLM_PROVIDER, apiKeyProvisional, token);
return result with
{
// The API is the authority: when it reports a model we also keep as a fallback above,
@@ -166,16 +236,14 @@ public sealed class ProviderAnthropic() : BaseProvider(LLMProviders.ANTHROPIC, n
{
return Task.FromResult(ModelLoadResult.FromModels([]));
}
#endregion
private Task<ModelLoadResult> LoadModels(SecretStoreType storeType, CancellationToken token, string? apiKeyProvisional = null)
private Task<ModelLoadResult> LoadModels(SecretStoreType storeType, string? apiKeyProvisional, CancellationToken token)
{
return this.LoadModelsResponse<ModelsResponse>(
storeType,
"models?limit=100",
modelResponse => modelResponse.Data,
token,
apiKeyProvisional,
failureReasonSelector: (response, _) => response.StatusCode switch
{
@@ -189,6 +257,6 @@ public sealed class ProviderAnthropic() : BaseProvider(LLMProviders.ANTHROPIC, n
request.Headers.Add("x-api-key", secretKey);
request.Headers.Add("anthropic-version", "2023-06-01");
},
jsonSerializerOptions: JSON_SERIALIZER_OPTIONS);
jsonSerializerOptions: JSON_SERIALIZER_OPTIONS, token: token);
}
}
@@ -29,11 +29,4 @@ public readonly record struct ResponseStreamLine(string Type, int Index, Delta D
public IList<ISource> GetSources() => [];
#endregion
}
/// <summary>
/// The delta object of a response line.
/// </summary>
/// <param name="Type">The type of the delta.</param>
/// <param name="Text">The text of the delta.</param>
public readonly record struct Delta(string Type, string Text);
}