mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-18 02:57:59 +00:00
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:
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);
|
||||
}
|
||||
Reference in new issue
Block a user