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

@@ -29,7 +29,7 @@ public sealed class ProviderAlibabaCloud() : BaseProvider(LLMProviders.ALIBABA_C
chatModel,
chatThread,
settingsManager,
async (systemPrompt, apiParameters) =>
async (systemPrompt, apiParameters, tools) =>
{
// Build the list of messages:
var messages = await chatThread.Blocks.BuildMessagesUsingNestedImageUrlAsync(this.Provider, chatModel);
@@ -44,6 +44,7 @@ public sealed class ProviderAlibabaCloud() : BaseProvider(LLMProviders.ALIBABA_C
Messages = [systemPrompt, ..messages],
Stream = true,
Tools = tools,
AdditionalApiParameters = apiParameters
};
},
@@ -102,7 +103,7 @@ public sealed class ProviderAlibabaCloud() : BaseProvider(LLMProviders.ALIBABA_C
new Model("qwen2.5-vl-3b-instruct", "Qwen2.5-VL 3b"),
};
var result = await this.LoadModels(["q"], SecretStoreType.LLM_PROVIDER, token, apiKeyProvisional);
var result = await this.LoadModels(["q"], 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,
@@ -126,7 +127,7 @@ public sealed class ProviderAlibabaCloud() : BaseProvider(LLMProviders.ALIBABA_C
new Model("text-embedding-v3", "text-embedding-v3"),
};
var result = await this.LoadModels(["text-embedding-"], SecretStoreType.EMBEDDING_PROVIDER, token, apiKeyProvisional);
var result = await this.LoadModels(["text-embedding-"], SecretStoreType.EMBEDDING_PROVIDER, apiKeyProvisional, token);
return result with
{
// The API is the authority: when it reports a model we also keep as a fallback above,
@@ -147,14 +148,12 @@ public sealed class ProviderAlibabaCloud() : BaseProvider(LLMProviders.ALIBABA_C
#endregion
private Task<ModelLoadResult> LoadModels(string[] prefixes, SecretStoreType storeType, CancellationToken token, string? apiKeyProvisional = null)
private Task<ModelLoadResult> LoadModels(string[] prefixes, SecretStoreType storeType, string? apiKeyProvisional, CancellationToken token)
{
return this.LoadModelsResponse<ModelsResponse>(
storeType,
"models",
modelResponse => modelResponse.Data.Where(model => prefixes.Any(prefix => model.Id.StartsWith(prefix, StringComparison.InvariantCulture))),
token,
apiKeyProvisional);
apiKeyProvisional, token: token);
}
}
@@ -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);
}
+115 -14
View File
@@ -10,6 +10,8 @@ using AIStudio.Provider.Anthropic;
using AIStudio.Provider.OpenAI;
using AIStudio.Provider.SelfHosted;
using AIStudio.Settings;
using AIStudio.Tools.ToolCallingSystem;
using AIStudio.Tools.ToolCallingSystem.Harness;
using AIStudio.Tools.MIME;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.Rust;
@@ -87,6 +89,8 @@ public abstract class BaseProvider : IProvider, ISecretId
/// <inheritdoc />
public string AdditionalJsonApiParameters { get; init; } = string.Empty;
internal ProviderCapabilityOverrides? CapabilityOverrides { get; set; }
/// <inheritdoc />
public abstract bool HasModelLoadingCapability { get; }
@@ -176,16 +180,15 @@ public abstract class BaseProvider : IProvider, ISecretId
_ => GetDefaultModelLoadFailureReason(response),
};
protected async Task<ModelLoadResult> LoadModelsResponse<TResponse>(
SecretStoreType storeType,
protected async Task<ModelLoadResult> LoadModelsResponse<TResponse>(SecretStoreType storeType,
string requestPath,
Func<TResponse, IEnumerable<Model>> modelFactory,
CancellationToken token,
string? apiKeyProvisional = null,
Func<HttpResponseMessage, string, ModelLoadFailureReason>? failureReasonSelector = null,
Action<HttpRequestMessage, string>? requestConfigurator = null,
JsonSerializerOptions? jsonSerializerOptions = null,
bool isTryingSecret = false)
bool isTryingSecret = false,
CancellationToken token = default)
{
var secretKey = await this.GetModelLoadingSecretKey(storeType, apiKeyProvisional, isTryingSecret);
if (string.IsNullOrWhiteSpace(secretKey) && !isTryingSecret)
@@ -443,6 +446,10 @@ public abstract class BaseProvider : IProvider, ISecretId
/// <summary>
/// Sends a request and handles rate limiting by exponential backoff.
/// </summary>
/// <remarks>
/// Two cancellation tokens, so one of them cannot be the last parameter: the user token
/// survives a retry, while the request token belongs to the single attempt being made.
/// </remarks>
/// <param name="requestBuilder">A function that builds the request.</param>
/// <param name="userCancellationToken">The user cancellation token.</param>
/// <param name="requestCancellationToken">The token to use for the HTTP request.</param>
@@ -1017,13 +1024,14 @@ public abstract class BaseProvider : IProvider, ISecretId
Model chatModel,
ChatThread chatThread,
SettingsManager settingsManager,
Func<TextMessage, IDictionary<string, object>, Task<TRequest>> requestFactory,
Func<TextMessage, IDictionary<string, object>, IList<object>?, Task<TRequest>> requestFactory,
SecretStoreType storeType = SecretStoreType.LLM_PROVIDER,
bool isTryingSecret = false,
string systemPromptRole = "system",
string requestPath = "chat/completions",
Action<HttpRequestHeaders>? headersAction = null,
[EnumeratorCancellation] CancellationToken token = default)
where TRequest : ChatCompletionAPIRequest
where TDelta : IResponseStreamLine
where TAnnotation : IAnnotationStreamLine
{
@@ -1032,18 +1040,69 @@ public abstract class BaseProvider : IProvider, ISecretId
if(!requestedSecret.Success && !isTryingSecret)
yield break;
// Prepare the system prompt:
var systemPrompt = new TextMessage
{
Role = systemPromptRole,
Content = chatThread.PrepareSystemPrompt(settingsManager),
};
// Parse the API parameters:
var apiParameters = this.ParseAdditionalApiParameters();
var apiParameters = this.ParseAdditionalApiParameters("parallel_tool_calls");
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();
TextMessage systemPrompt;
if (toolRegistry is not null && toolExecutor is not null)
{
var providerSettings = this.CreateSettingsProvider(chatModel);
var runnableTools = await toolRegistry.GetRunnableToolsAsync(
providerSettings,
chatThread.RuntimeComponent,
chatThread.RuntimeSelectedToolIds,
this.Provider.GetConfidence(settingsManager).Level,
chatThread.MayRunTools(settingsManager));
systemPrompt = new TextMessage
{
Role = systemPromptRole,
Content = chatThread.PrepareSystemPrompt(settingsManager, runnableTools.Select(x => x.Definition)),
};
if (runnableTools.Count > 0)
{
var adapter = new ChatCompletionToolCallingAdapter<TRequest>(requestFactory, systemPrompt, apiParameters,
runnableTools.Select(x => ProviderToolAdapters.ToChatCompletionTool(x.Definition)).ToList(), runnableTools,
(requestDto, requestToken) => this.ExecuteChatCompletionRequest(requestDto, requestPath, requestedSecret, headersAction, requestToken),
this.InstanceName, this.logger);
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;
}
}
else
{
systemPrompt = new TextMessage
{
Role = systemPromptRole,
Content = chatThread.PrepareSystemPrompt(settingsManager),
};
}
// Prepare the provider HTTP chat request:
var providerChatRequest = JsonSerializer.Serialize(await requestFactory(systemPrompt, apiParameters), JSON_SERIALIZER_OPTIONS);
var providerChatRequest = JsonSerializer.Serialize(await requestFactory(systemPrompt, apiParameters, null), JSON_SERIALIZER_OPTIONS);
async Task<HttpRequestMessage> RequestBuilder()
{
@@ -1066,6 +1125,44 @@ public abstract class BaseProvider : IProvider, ISecretId
yield return content;
}
/// <summary>
/// Describes this provider instance with the given model as configured provider settings.
/// </summary>
/// <remarks>
/// Anything asking about model capabilities must go through this, because the expert
/// capability overrides live on the settings object: a provider that builds its own settings
/// instance without them silently ignores what the user configured.
/// </remarks>
protected AIStudio.Settings.Provider CreateSettingsProvider(Model chatModel) => new()
{
UsedLLMProvider = this.Provider,
Model = chatModel,
InstanceName = this.InstanceName,
CapabilityOverrides = this.CapabilityOverrides,
};
private async Task<ChatCompletionResponse?> ExecuteChatCompletionRequest(ChatCompletionAPIRequest requestDto, string requestPath, RequestedSecret requestedSecret,
Action<HttpRequestHeaders>? headersAction, CancellationToken token)
{
var responseData = await this.SendRequest(RequestBuilder, token);
if (responseData.IsFailedAfterAllRetries)
return null;
using var response = responseData.Response!;
return await response.Content.ReadFromJsonAsync<ChatCompletionResponse>(JSON_SERIALIZER_OPTIONS, token);
async Task<HttpRequestMessage> RequestBuilder()
{
var request = new HttpRequestMessage(HttpMethod.Post, requestPath);
if (requestedSecret.Success)
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await requestedSecret.Secret.Decrypt(Program.ENCRYPTION));
headersAction?.Invoke(request.Headers);
request.Content = new StringContent(JsonSerializer.Serialize(requestDto, JSON_SERIALIZER_OPTIONS), Encoding.UTF8, "application/json");
return request;
}
}
/// <summary>
/// Builds the message a user gets to see when a transcription request failed.
/// </summary>
@@ -1199,6 +1296,10 @@ public abstract class BaseProvider : IProvider, ISecretId
}
}
/// <remarks>
/// The cancellation token is not the last parameter, unlike everywhere else in this codebase:
/// C# demands that a params parameter comes last.
/// </remarks>
protected async Task<IReadOnlyList<IReadOnlyList<float>>> PerformStandardTextEmbeddingRequest(RequestedSecret requestedSecret, Model embeddingModel, Host host = Host.NONE, CancellationToken token = default, params List<string> texts)
{
try
@@ -29,7 +29,7 @@ public sealed class ProviderDeepSeek() : BaseProvider(LLMProviders.DEEP_SEEK, ne
chatModel,
chatThread,
settingsManager,
async (systemPrompt, apiParameters) =>
async (systemPrompt, apiParameters, tools) =>
{
// Build the list of messages:
var messages = await chatThread.Blocks.BuildMessagesUsingDirectImageUrlAsync(this.Provider, chatModel);
@@ -44,6 +44,7 @@ public sealed class ProviderDeepSeek() : BaseProvider(LLMProviders.DEEP_SEEK, ne
Messages = [systemPrompt, ..messages],
Stream = true,
Tools = tools,
AdditionalApiParameters = apiParameters
};
},
@@ -74,7 +75,7 @@ public sealed class ProviderDeepSeek() : BaseProvider(LLMProviders.DEEP_SEEK, ne
/// <inheritdoc />
public override Task<ModelLoadResult> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default)
{
return this.LoadModels(SecretStoreType.LLM_PROVIDER, token, apiKeyProvisional);
return this.LoadModels(SecretStoreType.LLM_PROVIDER, apiKeyProvisional, token);
}
/// <inheritdoc />
@@ -97,13 +98,12 @@ public sealed class ProviderDeepSeek() : BaseProvider(LLMProviders.DEEP_SEEK, ne
#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",
modelResponse => modelResponse.Data.Where(model => model.IsChatModel()),
token,
apiKeyProvisional);
apiKeyProvisional, token: token);
}
}
@@ -0,0 +1,8 @@
namespace AIStudio.Provider.Fireworks;
/// <summary>
/// Data model for a choice made by the AI.
/// </summary>
/// <param name="Index">The index of the choice.</param>
/// <param name="Delta">The delta text of the choice.</param>
public readonly record struct Choice(int Index, Delta Delta);
@@ -0,0 +1,7 @@
namespace AIStudio.Provider.Fireworks;
/// <summary>
/// The delta text of a choice.
/// </summary>
/// <param name="Content">The content of the delta text.</param>
public readonly record struct Delta(string Content);
@@ -29,7 +29,7 @@ public class ProviderFireworks() : BaseProvider(LLMProviders.FIREWORKS, new Uri(
chatModel,
chatThread,
settingsManager,
async (systemPrompt, apiParameters) =>
async (systemPrompt, apiParameters, tools) =>
{
// Build the list of messages:
var messages = await chatThread.Blocks.BuildMessagesUsingNestedImageUrlAsync(this.Provider, chatModel);
@@ -45,6 +45,7 @@ public class ProviderFireworks() : BaseProvider(LLMProviders.FIREWORKS, new Uri(
// Right now, we only support streaming completions:
Stream = true,
Tools = tools,
AdditionalApiParameters = apiParameters
};
},
@@ -29,17 +29,4 @@ public readonly record struct ResponseStreamLine(string Id, string Object, uint
public IList<ISource> GetSources() => [];
#endregion
}
/// <summary>
/// Data model for a choice made by the AI.
/// </summary>
/// <param name="Index">The index of the choice.</param>
/// <param name="Delta">The delta text of the choice.</param>
public readonly record struct Choice(int Index, Delta Delta);
/// <summary>
/// The delta text of a choice.
/// </summary>
/// <param name="Content">The content of the delta text.</param>
public readonly record struct Delta(string Content);
}
@@ -37,7 +37,7 @@ public sealed class ProviderGWDG() : BaseProvider(LLMProviders.GWDG, new Uri("ht
chatModel,
chatThread,
settingsManager,
async (systemPrompt, apiParameters) =>
async (systemPrompt, apiParameters, tools) =>
{
// Build the list of messages:
var messages = await chatThread.Blocks.BuildMessagesUsingNestedImageUrlAsync(this.Provider, chatModel);
@@ -52,6 +52,7 @@ public sealed class ProviderGWDG() : BaseProvider(LLMProviders.GWDG, new Uri("ht
Messages = [systemPrompt, ..messages],
Stream = true,
Tools = tools,
AdditionalApiParameters = apiParameters
};
},
@@ -84,7 +85,7 @@ public sealed class ProviderGWDG() : BaseProvider(LLMProviders.GWDG, new Uri("ht
/// <inheritdoc />
public override async Task<ModelLoadResult> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default)
{
var result = await this.LoadModels(SecretStoreType.LLM_PROVIDER, token, apiKeyProvisional);
var result = await this.LoadModels(SecretStoreType.LLM_PROVIDER, apiKeyProvisional, token);
return result with
{
Models = [..result.Models.Where(model => model.IsChatModel())]
@@ -107,7 +108,7 @@ public sealed class ProviderGWDG() : BaseProvider(LLMProviders.GWDG, new Uri("ht
/// </remarks>
public override async Task<ModelLoadResult> GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default)
{
var result = await this.LoadModels(SecretStoreType.EMBEDDING_PROVIDER, token, apiKeyProvisional);
var result = await this.LoadModels(SecretStoreType.EMBEDDING_PROVIDER, apiKeyProvisional, token);
if (!result.Success)
return result;
@@ -133,14 +134,13 @@ public sealed class ProviderGWDG() : BaseProvider(LLMProviders.GWDG, new Uri("ht
#endregion
private async Task<ModelLoadResult> LoadModels(SecretStoreType storeType, CancellationToken token, string? apiKeyProvisional = null)
private async Task<ModelLoadResult> LoadModels(SecretStoreType storeType, string? apiKeyProvisional, CancellationToken token)
{
var result = await this.LoadModelsResponse<ModelsResponse>(
storeType,
"models",
modelResponse => modelResponse.Data,
token,
apiKeyProvisional);
apiKeyProvisional, token: token);
if (!result.Success)
LOGGER.LogWarning("Failed to load models for provider {ProviderId}. FailureReason: {FailureReason}. TechnicalDetails: {TechnicalDetails}", this.Id, result.FailureReason, result.TechnicalDetails);
@@ -31,7 +31,7 @@ public class ProviderGoogle() : BaseProvider(LLMProviders.GOOGLE, new Uri("https
chatModel,
chatThread,
settingsManager,
async (systemPrompt, apiParameters) =>
async (systemPrompt, apiParameters, tools) =>
{
// Build the list of messages:
var messages = await chatThread.Blocks.BuildMessagesUsingNestedImageUrlAsync(this.Provider, chatModel);
@@ -47,6 +47,7 @@ public class ProviderGoogle() : BaseProvider(LLMProviders.GOOGLE, new Uri("https
// Right now, we only support streaming completions:
Stream = true,
Tools = tools,
AdditionalApiParameters = apiParameters
};
},
@@ -146,7 +147,7 @@ public class ProviderGoogle() : BaseProvider(LLMProviders.GOOGLE, new Uri("https
/// <inheritdoc />
public override async Task<ModelLoadResult> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default)
{
var result = await this.LoadModels(SecretStoreType.LLM_PROVIDER, token, apiKeyProvisional);
var result = await this.LoadModels(SecretStoreType.LLM_PROVIDER, apiKeyProvisional, token);
return result with
{
Models =
@@ -167,7 +168,7 @@ public class ProviderGoogle() : BaseProvider(LLMProviders.GOOGLE, new Uri("https
public override async Task<ModelLoadResult> GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default)
{
var result = await this.LoadModels(SecretStoreType.EMBEDDING_PROVIDER, token, apiKeyProvisional);
var result = await this.LoadModels(SecretStoreType.EMBEDDING_PROVIDER, apiKeyProvisional, token);
return result with
{
Models =
@@ -186,7 +187,7 @@ public class ProviderGoogle() : BaseProvider(LLMProviders.GOOGLE, new Uri("https
#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,
@@ -194,7 +195,6 @@ public class ProviderGoogle() : BaseProvider(LLMProviders.GOOGLE, new Uri("https
modelResponse => modelResponse.Data
.Where(model => !string.IsNullOrWhiteSpace(model.Id))
.Select(model => new Model(this.NormalizeModelId(model.Id), model.DisplayName)),
token,
apiKeyProvisional,
failureReasonSelector: (response, _) => response.StatusCode switch
{
@@ -202,7 +202,8 @@ public class ProviderGoogle() : BaseProvider(LLMProviders.GOOGLE, new Uri("https
System.Net.HttpStatusCode.Unauthorized => ModelLoadFailureReason.INVALID_OR_MISSING_API_KEY,
System.Net.HttpStatusCode.TooManyRequests => ModelLoadFailureReason.TOO_MANY_REQUESTS,
_ => ModelLoadFailureReason.PROVIDER_UNAVAILABLE,
});
},
token: token);
}
private bool IsEmbeddingModel(string modelId)
@@ -29,7 +29,7 @@ public class ProviderGroq() : BaseProvider(LLMProviders.GROQ, new Uri("https://a
chatModel,
chatThread,
settingsManager,
async (systemPrompt, apiParameters) =>
async (systemPrompt, apiParameters, tools) =>
{
if (TryPopIntParameter(apiParameters, "seed", out var parsedSeed))
apiParameters["seed"] = parsedSeed;
@@ -48,6 +48,7 @@ public class ProviderGroq() : BaseProvider(LLMProviders.GROQ, new Uri("https://a
// Right now, we only support streaming completions:
Stream = true,
Tools = tools,
AdditionalApiParameters = apiParameters
};
},
@@ -79,7 +80,7 @@ public class ProviderGroq() : BaseProvider(LLMProviders.GROQ, new Uri("https://a
/// <inheritdoc />
public override async Task<ModelLoadResult> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default)
{
var result = await this.LoadModels(SecretStoreType.LLM_PROVIDER, token, apiKeyProvisional);
var result = await this.LoadModels(SecretStoreType.LLM_PROVIDER, apiKeyProvisional, token);
return result with
{
Models = [..result.Models.Where(model => model.IsChatModel())]
@@ -101,7 +102,7 @@ public class ProviderGroq() : BaseProvider(LLMProviders.GROQ, new Uri("https://a
/// <inheritdoc />
public override async Task<ModelLoadResult> GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default)
{
var result = await this.LoadModels(SecretStoreType.TRANSCRIPTION_PROVIDER, token, apiKeyProvisional);
var result = await this.LoadModels(SecretStoreType.TRANSCRIPTION_PROVIDER, apiKeyProvisional, token);
return result with
{
Models = [..result.Models.Where(model => model.IsTranscriptionModel())]
@@ -110,13 +111,12 @@ public class ProviderGroq() : BaseProvider(LLMProviders.GROQ, new Uri("https://a
#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",
modelResponse => modelResponse.Data,
token,
apiKeyProvisional);
apiKeyProvisional, token: token);
}
}
}
@@ -31,7 +31,7 @@ public sealed class ProviderHelmholtz() : BaseProvider(LLMProviders.HELMHOLTZ, n
chatModel,
chatThread,
settingsManager,
async (systemPrompt, apiParameters) =>
async (systemPrompt, apiParameters, tools) =>
{
// Build the list of messages:
var messages = await chatThread.Blocks.BuildMessagesUsingNestedImageUrlAsync(this.Provider, chatModel);
@@ -46,6 +46,7 @@ public sealed class ProviderHelmholtz() : BaseProvider(LLMProviders.HELMHOLTZ, n
Messages = [systemPrompt, ..messages],
Stream = true,
Tools = tools,
AdditionalApiParameters = apiParameters
};
},
@@ -78,7 +79,7 @@ public sealed class ProviderHelmholtz() : BaseProvider(LLMProviders.HELMHOLTZ, n
/// <inheritdoc />
public override async Task<ModelLoadResult> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default)
{
var result = await this.LoadModels(SecretStoreType.LLM_PROVIDER, token, apiKeyProvisional);
var result = await this.LoadModels(SecretStoreType.LLM_PROVIDER, apiKeyProvisional, token);
return result with
{
Models =
@@ -97,7 +98,7 @@ public sealed class ProviderHelmholtz() : BaseProvider(LLMProviders.HELMHOLTZ, n
/// <inheritdoc />
public override async Task<ModelLoadResult> GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default)
{
var result = await this.LoadModels(SecretStoreType.EMBEDDING_PROVIDER, token, apiKeyProvisional);
var result = await this.LoadModels(SecretStoreType.EMBEDDING_PROVIDER, apiKeyProvisional, token);
return result with
{
Models =
@@ -110,7 +111,7 @@ public sealed class ProviderHelmholtz() : BaseProvider(LLMProviders.HELMHOLTZ, n
/// <inheritdoc />
public override async Task<ModelLoadResult> GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default)
{
var result = await this.LoadModels(SecretStoreType.TRANSCRIPTION_PROVIDER, token, apiKeyProvisional);
var result = await this.LoadModels(SecretStoreType.TRANSCRIPTION_PROVIDER, apiKeyProvisional, token);
return result with
{
Models =
@@ -122,7 +123,7 @@ public sealed class ProviderHelmholtz() : BaseProvider(LLMProviders.HELMHOLTZ, n
#endregion
private async Task<ModelLoadResult> LoadModels(SecretStoreType storeType, CancellationToken token, string? apiKeyProvisional = null)
private async Task<ModelLoadResult> LoadModels(SecretStoreType storeType, string? apiKeyProvisional, CancellationToken token)
{
var secretKey = await this.GetModelLoadingSecretKey(storeType, apiKeyProvisional);
if (string.IsNullOrWhiteSpace(secretKey))
@@ -164,4 +165,4 @@ public sealed class ProviderHelmholtz() : BaseProvider(LLMProviders.HELMHOLTZ, n
return FailedModelLoadResult(ModelLoadFailureReason.PROVIDER_UNAVAILABLE, e.Message);
}
}
}
}
@@ -29,7 +29,7 @@ public sealed class ProviderHetzner() : BaseProvider(LLMProviders.HETZNER, new U
chatModel,
chatThread,
settingsManager,
async (systemPrompt, apiParameters) =>
async (systemPrompt, apiParameters, tools) =>
{
var messages = await chatThread.Blocks.BuildMessagesUsingNestedImageUrlAsync(this.Provider, chatModel);
@@ -38,6 +38,7 @@ public sealed class ProviderHetzner() : BaseProvider(LLMProviders.HETZNER, new U
Model = chatModel.Id,
Messages = [systemPrompt, ..messages],
Stream = true,
Tools = tools,
AdditionalApiParameters = apiParameters
};
},
@@ -68,7 +69,7 @@ public sealed class ProviderHetzner() : BaseProvider(LLMProviders.HETZNER, new U
/// <inheritdoc />
public override Task<ModelLoadResult> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default)
{
return this.LoadModelsResponse<ModelsResponse>(SecretStoreType.LLM_PROVIDER, "models", modelResponse => modelResponse.Data.Where(model => model.IsChatModel()), token, apiKeyProvisional);
return this.LoadModelsResponse<ModelsResponse>(SecretStoreType.LLM_PROVIDER, "models", modelResponse => modelResponse.Data.Where(model => model.IsChatModel()), apiKeyProvisional, token: token);
}
/// <inheritdoc />
@@ -0,0 +1,8 @@
namespace AIStudio.Provider.HuggingFace;
/// <summary>
/// One model as the Hugging Face router describes it.
/// </summary>
/// <param name="Id">The ID of the model, written as "org/model".</param>
/// <param name="Providers">The inference providers serving this model.</param>
public readonly record struct HFModel(string Id, IList<HFModelProvider>? Providers);
@@ -0,0 +1,8 @@
namespace AIStudio.Provider.HuggingFace;
/// <summary>
/// One inference provider serving a model.
/// </summary>
/// <param name="Provider">The slug of the inference provider, e.g. "novita".</param>
/// <param name="Status">Whether the provider currently serves the model. Known value: "live".</param>
public readonly record struct HFModelProvider(string Provider, string Status);
@@ -0,0 +1,12 @@
namespace AIStudio.Provider.HuggingFace;
/// <summary>
/// One model as the Hugging Face hub lists it.
/// </summary>
/// <remarks>
/// The hub answers with a plain array of models and describes each of them in far more detail than
/// we need here, from tags to download counts. We only ever ask for the models of one provider and
/// one task, so the ID is all that is left to read.
/// </remarks>
/// <param name="Id">The ID of the model, written as "org/model".</param>
public readonly record struct HubModel(string Id);
@@ -9,29 +9,4 @@ namespace AIStudio.Provider.HuggingFace;
/// instead of using the shared one.
/// </remarks>
/// <param name="Data">The models the router knows.</param>
public readonly record struct ModelsResponse(IList<HFModel> Data);
/// <summary>
/// One model as the Hugging Face router describes it.
/// </summary>
/// <param name="Id">The ID of the model, written as "org/model".</param>
/// <param name="Providers">The inference providers serving this model.</param>
public readonly record struct HFModel(string Id, IList<HFModelProvider>? Providers);
/// <summary>
/// One inference provider serving a model.
/// </summary>
/// <param name="Provider">The slug of the inference provider, e.g. "novita".</param>
/// <param name="Status">Whether the provider currently serves the model. Known value: "live".</param>
public readonly record struct HFModelProvider(string Provider, string Status);
/// <summary>
/// One model as the Hugging Face hub lists it.
/// </summary>
/// <remarks>
/// The hub answers with a plain array of models and describes each of them in far more detail than
/// we need here, from tags to download counts. We only ever ask for the models of one provider and
/// one task, so the ID is all that is left to read.
/// </remarks>
/// <param name="Id">The ID of the model, written as "org/model".</param>
public readonly record struct HubModel(string Id);
public readonly record struct ModelsResponse(IList<HFModel> Data);
@@ -163,7 +163,7 @@ public sealed class ProviderHuggingFace : BaseProvider
chatModel,
chatThread,
settingsManager,
async (systemPrompt, apiParameters) =>
async (systemPrompt, apiParameters, tools) =>
{
// Build the list of messages:
var messages = await chatThread.Blocks.BuildMessagesUsingNestedImageUrlAsync(this.Provider, chatModel);
@@ -178,6 +178,7 @@ public sealed class ProviderHuggingFace : BaseProvider
Messages = [systemPrompt, ..messages],
Stream = true,
Tools = tools,
AdditionalApiParameters = apiParameters
};
},
@@ -220,7 +221,7 @@ public sealed class ProviderHuggingFace : BaseProvider
/// <inheritdoc />
public override Task<ModelLoadResult> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default)
{
return this.LoadModelsResponse<ModelsResponse>(SecretStoreType.LLM_PROVIDER, "models", this.SelectChatModels, token, apiKeyProvisional);
return this.LoadModelsResponse<ModelsResponse>(SecretStoreType.LLM_PROVIDER, "models", this.SelectChatModels, apiKeyProvisional, token: token);
}
/// <summary>
@@ -282,7 +283,7 @@ public sealed class ProviderHuggingFace : BaseProvider
private Task<ModelLoadResult> LoadHubModels(SecretStoreType storeType, string pipelineTag, string? apiKeyProvisional, CancellationToken token)
{
var requestURL = $"{HUB_MODELS_URL}{this.hfProvider.EndpointsId()}&pipeline_tag={pipelineTag}";
return this.LoadModelsResponse<IList<HubModel>>(storeType, requestURL, hubModels => hubModels.Select(hubModel => new Model(hubModel.Id, null)), token, apiKeyProvisional);
return this.LoadModelsResponse<IList<HubModel>>(storeType, requestURL, hubModels => hubModels.Select(hubModel => new Model(hubModel.Id, null)), apiKeyProvisional, token: token);
}
/// <inheritdoc />
@@ -36,7 +36,7 @@ public sealed class ProviderIONOS() : BaseProvider(LLMProviders.IONOS, new Uri("
chatModel,
chatThread,
settingsManager,
async (systemPrompt, apiParameters) =>
async (systemPrompt, apiParameters, tools) =>
{
// Build the list of messages:
var messages = await chatThread.Blocks.BuildMessagesUsingNestedImageUrlAsync(this.Provider, chatModel);
@@ -52,6 +52,7 @@ public sealed class ProviderIONOS() : BaseProvider(LLMProviders.IONOS, new Uri("
// Right now, we only support streaming completions:
Stream = true,
Tools = tools,
AdditionalApiParameters = apiParameters
};
},
@@ -83,7 +84,7 @@ public sealed class ProviderIONOS() : BaseProvider(LLMProviders.IONOS, new Uri("
/// <inheritdoc />
public override Task<ModelLoadResult> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default)
{
return this.LoadModels(SecretStoreType.LLM_PROVIDER, model => model.IsChatModel(), token, apiKeyProvisional);
return this.LoadModels(SecretStoreType.LLM_PROVIDER, model => model.IsChatModel(), apiKeyProvisional, token);
}
/// <inheritdoc />
@@ -95,7 +96,7 @@ public sealed class ProviderIONOS() : BaseProvider(LLMProviders.IONOS, new Uri("
/// <inheritdoc />
public override Task<ModelLoadResult> GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default)
{
return this.LoadModels(SecretStoreType.EMBEDDING_PROVIDER, model => model.IsEmbeddingModel(), token, apiKeyProvisional);
return this.LoadModels(SecretStoreType.EMBEDDING_PROVIDER, model => model.IsEmbeddingModel(), apiKeyProvisional, token);
}
/// <inheritdoc />
@@ -116,10 +117,10 @@ public sealed class ProviderIONOS() : BaseProvider(LLMProviders.IONOS, new Uri("
/// </remarks>
/// <param name="storeType">The secret store to read the API key from.</param>
/// <param name="isWantedKind">Decides whether a model belongs to the requested kind.</param>
/// <param name="token">The cancellation token.</param>
/// <param name="apiKeyProvisional">An API key which was not stored yet.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The models of the requested kind.</returns>
private Task<ModelLoadResult> LoadModels(SecretStoreType storeType, Func<Model, bool> isWantedKind, CancellationToken token, string? apiKeyProvisional = null)
private Task<ModelLoadResult> LoadModels(SecretStoreType storeType, Func<Model, bool> isWantedKind, string? apiKeyProvisional, CancellationToken token)
{
return this.LoadModelsResponse<ModelsResponse>(
storeType,
@@ -127,7 +128,6 @@ public sealed class ProviderIONOS() : BaseProvider(LLMProviders.IONOS, new Uri("
modelResponse => modelResponse.Data
.Where(model => !model.Id.EndsWith(MIGRATION_ALIAS_SUFFIX, StringComparison.OrdinalIgnoreCase))
.Where(isWantedKind),
token,
apiKeyProvisional);
apiKeyProvisional, token: token);
}
}
+5 -1
View File
@@ -74,10 +74,14 @@ public interface IProvider
/// <summary>
/// Embed a text file.
/// </summary>
/// <remarks>
/// The cancellation token is not the last parameter, unlike everywhere else in this codebase:
/// C# demands that a params parameter comes last, and every implementation inherits that order.
/// </remarks>
/// <param name="embeddingModel">The model to use for embedding.</param>
/// <param name="settingsManager">The settings manager instance to use.</param>
/// <param name="token">The cancellation token.</param>
/// /// <param name="texts">A single string or a list of strings to embed.</param>
/// <param name="texts">A single string or a list of strings to embed.</param>
/// <returns>>The embedded text as a single vector or as a list of vectors.</returns>
public Task<IReadOnlyList<IReadOnlyList<float>>> EmbedTextAsync(Model embeddingModel, SettingsManager settingsManager, CancellationToken token = default, params List<string> texts);
@@ -278,7 +278,7 @@ public static class LLMProvidersExtensions
/// <returns>The provider instance.</returns>
public static IProvider CreateProvider(this AIStudio.Settings.Provider providerSettings)
{
return providerSettings.UsedLLMProvider.CreateProvider(providerSettings.InstanceName, providerSettings.Host, providerSettings.Hostname, providerSettings.HFInferenceProvider, providerSettings.Id, providerSettings.AdditionalJsonApiParameters, providerSettings.IsEnterpriseConfiguration);
return providerSettings.UsedLLMProvider.CreateProvider(providerSettings.InstanceName, providerSettings.Host, providerSettings.Hostname, providerSettings.HFInferenceProvider, providerSettings.Id, providerSettings.AdditionalJsonApiParameters, providerSettings.IsEnterpriseConfiguration, capabilityOverrides: providerSettings.CapabilityOverrides);
}
/// <summary>
@@ -301,11 +301,11 @@ public static class LLMProvidersExtensions
return transcriptionProviderSettings.UsedLLMProvider.CreateProvider(transcriptionProviderSettings.Name, transcriptionProviderSettings.Host, transcriptionProviderSettings.Hostname, transcriptionProviderSettings.HFInferenceProvider, configuredProviderId: transcriptionProviderSettings.Id, isEnterpriseConfiguration: transcriptionProviderSettings.IsEnterpriseConfiguration, hfEndpointKind: HFEndpointKind.TRANSCRIPTION);
}
private static IProvider CreateProvider(this LLMProviders provider, string instanceName, Host host, string hostname, HFInferenceProvider inferenceProvider, string configuredProviderId = "", string expertProviderApiParameter = "", bool isEnterpriseConfiguration = false, HFEndpointKind hfEndpointKind = HFEndpointKind.CHAT)
private static IProvider CreateProvider(this LLMProviders provider, string instanceName, Host host, string hostname, HFInferenceProvider inferenceProvider, string configuredProviderId = "", string expertProviderApiParameter = "", bool isEnterpriseConfiguration = false, HFEndpointKind hfEndpointKind = HFEndpointKind.CHAT, ProviderCapabilityOverrides? capabilityOverrides = null)
{
try
{
return provider switch
IProvider providerInstance = provider switch
{
LLMProviders.OPEN_AI => new ProviderOpenAI { InstanceName = instanceName, ConfiguredProviderId = configuredProviderId, AdditionalJsonApiParameters = expertProviderApiParameter, IsEnterpriseConfiguration = isEnterpriseConfiguration },
LLMProviders.ANTHROPIC => new ProviderAnthropic { InstanceName = instanceName, ConfiguredProviderId = configuredProviderId, AdditionalJsonApiParameters = expertProviderApiParameter, IsEnterpriseConfiguration = isEnterpriseConfiguration },
@@ -331,6 +331,11 @@ public static class LLMProvidersExtensions
_ => new NoProvider(),
};
if (providerInstance is BaseProvider baseProvider)
baseProvider.CapabilityOverrides = capabilityOverrides;
return providerInstance;
}
catch (Exception e)
{
@@ -547,4 +552,4 @@ public static class LLMProvidersExtensions
LLMProviders.HUGGINGFACE => true,
_ => false,
};
}
}
@@ -29,7 +29,7 @@ public sealed class ProviderLiteLLM(string hostname) : BaseProvider(LLMProviders
chatModel,
chatThread,
settingsManager,
async (systemPrompt, apiParameters) =>
async (systemPrompt, apiParameters, tools) =>
{
// Build the list of messages:
var messages = await chatThread.Blocks.BuildMessagesUsingDirectImageUrlAsync(this.Provider, chatModel);
@@ -44,6 +44,7 @@ public sealed class ProviderLiteLLM(string hostname) : BaseProvider(LLMProviders
Messages = [systemPrompt, ..messages],
Stream = true,
Tools = tools,
AdditionalApiParameters = apiParameters
};
},
@@ -76,7 +77,7 @@ public sealed class ProviderLiteLLM(string hostname) : BaseProvider(LLMProviders
/// <inheritdoc />
public override Task<ModelLoadResult> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default)
{
return this.LoadModels(SecretStoreType.LLM_PROVIDER, static model => model.IsChatModel(), token, apiKeyProvisional);
return this.LoadModels(SecretStoreType.LLM_PROVIDER, static model => model.IsChatModel(), apiKeyProvisional, token);
}
/// <inheritdoc />
@@ -88,13 +89,13 @@ public sealed class ProviderLiteLLM(string hostname) : BaseProvider(LLMProviders
/// <inheritdoc />
public override Task<ModelLoadResult> GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default)
{
return this.LoadModels(SecretStoreType.EMBEDDING_PROVIDER, static model => model.IsEmbeddingModel(), token, apiKeyProvisional);
return this.LoadModels(SecretStoreType.EMBEDDING_PROVIDER, static model => model.IsEmbeddingModel(), apiKeyProvisional, token);
}
/// <inheritdoc />
public override Task<ModelLoadResult> GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default)
{
return this.LoadModels(SecretStoreType.TRANSCRIPTION_PROVIDER, static model => model.IsTranscriptionModel(), token, apiKeyProvisional);
return this.LoadModels(SecretStoreType.TRANSCRIPTION_PROVIDER, static model => model.IsTranscriptionModel(), apiKeyProvisional, token);
}
#endregion
@@ -108,7 +109,7 @@ public sealed class ProviderLiteLLM(string hostname) : BaseProvider(LLMProviders
return new Uri($"{normalizedHostname}/v1/");
}
private Task<ModelLoadResult> LoadModels(SecretStoreType storeType, Func<Model, bool> isWantedKind, CancellationToken token, string? apiKeyProvisional = null)
private Task<ModelLoadResult> LoadModels(SecretStoreType storeType, Func<Model, bool> isWantedKind, string? apiKeyProvisional, CancellationToken token)
{
//
// The gateway serves every kind of model through one endpoint, so we have to sort
@@ -119,8 +120,7 @@ public sealed class ProviderLiteLLM(string hostname) : BaseProvider(LLMProviders
storeType,
"models",
modelResponse => modelResponse.Data.Where(IsRealModel).Where(isWantedKind),
token,
apiKeyProvisional);
apiKeyProvisional, token: token);
}
/// <summary>
@@ -0,0 +1,3 @@
namespace AIStudio.Provider.Mistral;
public readonly record struct Model(string Id, string Object, int Created, string OwnedBy);
@@ -1,5 +1,3 @@
namespace AIStudio.Provider.Mistral;
public readonly record struct ModelsResponse(string Object, Model[] Data);
public readonly record struct Model(string Id, string Object, int Created, string OwnedBy);
public readonly record struct ModelsResponse(string Object, Model[] Data);
@@ -29,7 +29,7 @@ public sealed class ProviderMistral() : BaseProvider(LLMProviders.MISTRAL, new U
chatModel,
chatThread,
settingsManager,
async (systemPrompt, apiParameters) =>
async (systemPrompt, apiParameters, tools) =>
{
if (TryPopBoolParameter(apiParameters, "safe_prompt", out var parsedSafePrompt))
apiParameters["safe_prompt"] = parsedSafePrompt;
@@ -51,6 +51,7 @@ public sealed class ProviderMistral() : BaseProvider(LLMProviders.MISTRAL, new U
// Right now, we only support streaming completions:
Stream = true,
Tools = tools,
AdditionalApiParameters = apiParameters
};
},
@@ -138,7 +139,6 @@ public sealed class ProviderMistral() : BaseProvider(LLMProviders.MISTRAL, new U
storeType,
"models",
modelResponse => modelResponse.Data.Select(n => new Provider.Model(n.Id, null)),
token,
apiKeyProvisional);
apiKeyProvisional, token: token);
}
}
}
@@ -0,0 +1,16 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace AIStudio.Provider.OpenAI;
public sealed record AssistantToolCallMessage : IMessageBase
{
public string Role { get; init; } = "assistant";
public JsonElement? Content { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ReasoningContent { get; init; }
public IList<ChatCompletionToolCall> ToolCalls { get; init; } = [];
}
@@ -17,8 +17,14 @@ public record ChatCompletionAPIRequest(
public ChatCompletionAPIRequest() : this(string.Empty, [], true)
{
}
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IList<object>? Tools { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? ParallelToolCalls { get; init; }
// Attention: The "required" modifier is not supported for [JsonExtensionData].
[JsonExtensionData]
public IDictionary<string, object> AdditionalApiParameters { get; init; } = new Dictionary<string, object>();
}
}
@@ -7,7 +7,7 @@ namespace AIStudio.Provider.OpenAI;
/// <param name="Delta">The delta text of the choice.</param>
public record ChatCompletionChoice(int Index, ChatCompletionDelta Delta)
{
public ChatCompletionChoice() : this(0, new (string.Empty))
public ChatCompletionChoice() : this(0, new())
{
}
}
}
@@ -0,0 +1,41 @@
using System.Text;
using System.Text.Json;
namespace AIStudio.Provider.OpenAI;
internal static class ChatCompletionContent
{
public static string? GetText(JsonElement? content)
{
if (content is not { } value)
return null;
if (value.ValueKind is JsonValueKind.String)
return value.GetString();
if (value.ValueKind is not JsonValueKind.Array)
return null;
var text = new StringBuilder();
foreach (var chunk in value.EnumerateArray())
{
if (chunk.ValueKind is JsonValueKind.String)
{
text.Append(chunk.GetString());
continue;
}
if (chunk.ValueKind is not JsonValueKind.Object ||
!chunk.TryGetProperty("type", out var type) ||
type.ValueKind is not JsonValueKind.String ||
!string.Equals(type.GetString(), "text", StringComparison.Ordinal) ||
!chunk.TryGetProperty("text", out var textElement) ||
textElement.ValueKind is not JsonValueKind.String)
continue;
text.Append(textElement.GetString());
}
return text.Length == 0 ? null : text.ToString();
}
}
@@ -1,12 +1,16 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace AIStudio.Provider.OpenAI;
/// <summary>
/// The delta text of a choice.
/// </summary>
/// <param name="Content">The content of the delta text.</param>
public record ChatCompletionDelta(string Content)
public sealed record ChatCompletionDelta
{
public ChatCompletionDelta() : this(string.Empty)
{
}
}
[JsonPropertyName("content")]
public JsonElement? RawContent { get; init; }
[JsonIgnore]
public string Content => ChatCompletionContent.GetText(this.RawContent) ?? string.Empty;
}
@@ -0,0 +1,10 @@
namespace AIStudio.Provider.OpenAI;
public sealed record ChatCompletionResponse
{
public string Id { get; init; } = string.Empty;
public string Model { get; init; } = string.Empty;
public IList<ChatCompletionResponseChoice> Choices { get; init; } = [];
}
@@ -0,0 +1,10 @@
namespace AIStudio.Provider.OpenAI;
public sealed record ChatCompletionResponseChoice
{
public int Index { get; init; }
public string FinishReason { get; init; } = string.Empty;
public ChatCompletionResponseMessage Message { get; init; } = new();
}
@@ -0,0 +1,19 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace AIStudio.Provider.OpenAI;
public sealed record ChatCompletionResponseMessage
{
public string Role { get; init; } = string.Empty;
[JsonPropertyName("content")]
public JsonElement? RawContent { get; init; }
[JsonIgnore]
public string? Content => ChatCompletionContent.GetText(this.RawContent);
public string? ReasoningContent { get; init; }
public IList<ChatCompletionToolCall?>? ToolCalls { get; init; }
}
@@ -0,0 +1,16 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace AIStudio.Provider.OpenAI;
public sealed record ChatCompletionToolCall
{
public string? Id { get; init; }
public string? Type { get; init; } = "function";
public ChatCompletionToolFunction? Function { get; init; }
[JsonExtensionData]
public IDictionary<string, JsonElement> AdditionalMetadata { get; init; } = new Dictionary<string, JsonElement>();
}
@@ -0,0 +1,171 @@
using System.Text.Json;
using AIStudio.Tools.ToolCallingSystem;
using AIStudio.Tools.ToolCallingSystem.Harness;
namespace AIStudio.Provider.OpenAI;
/// <summary>
/// Speaks the Chat Completions wire format for the tool calling loop.
/// </summary>
/// <remarks>
/// Tool calls arrive in the assistant message, and results go back as tool messages correlated by
/// tool call ID. Every OpenAI-compatible provider uses this shape.
/// </remarks>
public sealed class ChatCompletionToolCallingAdapter<TRequest>(
Func<TextMessage, IDictionary<string, object>, IList<object>?, Task<TRequest>> requestFactory,
TextMessage systemPrompt, IDictionary<string, object> apiParameters,
IList<object> providerTools,
IReadOnlyList<(ToolDefinition Definition, IToolImplementation Implementation)> runnableTools,
Func<ChatCompletionAPIRequest, CancellationToken, Task<ChatCompletionResponse?>> executeRequestAsync,
string providerInstanceName, ILogger logger)
: IToolCallingProviderAdapter where TRequest : ChatCompletionAPIRequest
{
private readonly List<IMessageBase> internalMessages = [];
private ChatCompletionResponseMessage? lastResponseMessage;
private List<ChatCompletionToolCall> lastToolCalls = [];
/// <inheritdoc />
public async Task<ToolCallingRound?> ExecuteRoundAsync(string? finalResponseInstruction, bool includeTools, CancellationToken token = default)
{
var requestSystemPrompt = finalResponseInstruction is null
? systemPrompt : systemPrompt with
{
Content = $"{systemPrompt.Content}{Environment.NewLine}{Environment.NewLine}{finalResponseInstruction}",
};
ChatCompletionAPIRequest requestDtoBase = await requestFactory(requestSystemPrompt, apiParameters, includeTools ? providerTools : null);
var requestDto = requestDtoBase with
{
Messages = [..requestDtoBase.Messages, ..this.internalMessages],
Stream = false,
//
// AI Studio runs tool calls one after another, so asking for parallel calls would
// only produce work it then has to serialize anyway. Requests without tools omit the
// parameter because some providers reject it then.
//
ParallelToolCalls = requestDtoBase.Tools is null ? null : false,
};
var response = await executeRequestAsync(requestDto, token);
if (response is null)
return null;
// The response comes from a provider, so its shape is a promise rather than a guarantee:
// a JSON null for the choices field overwrites the initialized property with null.
// ReSharper disable once ConditionalAccessQualifierIsNonNullableAccordingToAPIContract
var responseChoice = response.Choices?.FirstOrDefault();
if (responseChoice?.Message is null)
{
logger.LogError(
"The tool calling response did not contain a usable choice. ProviderInstanceName={ProviderInstanceName}, ChoiceCount={ChoiceCount}",
providerInstanceName,
response.Choices?.Count ?? 0);
throw ToolCallingMessages.InvalidToolCallingResponse(providerInstanceName);
}
this.lastResponseMessage = responseChoice.Message;
var preparedCalls = this.PrepareToolCalls(responseChoice.Message.ToolCalls ?? []);
this.lastToolCalls = preparedCalls.Select(x => x.ToolCall).ToList();
return new ToolCallingRound(
responseChoice.Message.Content ?? string.Empty,
preparedCalls
.Select(x => new ToolCallingRequestedCall(x.ToolCall.Id!, x.ToolCall.Function!.Name!, x.ToolCall.Function!.Arguments!, x.IsValid))
.ToList(),
[]);
}
/// <inheritdoc />
public void RecordAssistantTurn() => this.internalMessages.Add(new AssistantToolCallMessage
{
Content = this.lastResponseMessage?.RawContent,
ReasoningContent = this.lastResponseMessage?.ReasoningContent,
ToolCalls = this.lastToolCalls,
});
/// <inheritdoc />
/// <remarks>
/// Chat Completions has no error flag on a tool message, so a failure travels in the content
/// like any other result.
/// </remarks>
public void RecordToolResult(string callId, string content, bool isError = false) => this.internalMessages.Add(new ToolResultMessage
{
Content = content,
ToolCallId = callId,
});
/// <summary>
/// Normalizes the tool calls of one response.
/// </summary>
/// <remarks>
/// Models get this wrong in several ways: a missing call ID, a missing function name, or
/// arguments that are not valid JSON. None of that may reach a tool, but none of it may be
/// dropped either — a call the model never hears about again leaves it waiting. So each call
/// is either marked invalid and answered with an error, or corrected where that is safe.
/// </remarks>
private List<PreparedChatCompletionToolCall> PrepareToolCalls(IEnumerable<ChatCompletionToolCall?> toolCalls)
{
var preparedToolCalls = new List<PreparedChatCompletionToolCall>();
foreach (var returnedToolCall in toolCalls)
{
//
// Unlike the Responses API, Chat Completions does not need the ID to come from the
// provider: it only has to match between our request and our answer. So a missing one
// can be supplied instead of failing the call.
//
var toolCallId = string.IsNullOrWhiteSpace(returnedToolCall?.Id)
? $"call_{Guid.NewGuid():N}"
: returnedToolCall.Id;
var returnedFunctionName = returnedToolCall?.Function?.Name;
var returnedArguments = returnedToolCall?.Function?.Arguments;
var isValid = returnedToolCall?.Function is not null &&
!string.IsNullOrWhiteSpace(returnedFunctionName) &&
ToolExecutor.IsValidArgumentsJson(returnedArguments);
var normalizedToolCall = new ChatCompletionToolCall
{
Id = toolCallId,
Type = string.IsNullOrWhiteSpace(returnedToolCall?.Type) ? "function" : returnedToolCall.Type,
AdditionalMetadata = returnedToolCall?.AdditionalMetadata ?? new Dictionary<string, JsonElement>(),
Function = new ChatCompletionToolFunction
{
Name = string.IsNullOrWhiteSpace(returnedFunctionName) ? "invalid_tool_call" : returnedFunctionName,
Arguments = returnedArguments ?? "{}",
},
};
if (!isValid)
{
logger.LogWarning("Received an invalid Chat Completions tool call. ToolCallId={ToolCallId}", toolCallId);
preparedToolCalls.Add(new PreparedChatCompletionToolCall(normalizedToolCall, false));
continue;
}
var canonicalName = runnableTools
.Select(x => x.Definition.Function.Name)
.FirstOrDefault(x => x.Equals(returnedFunctionName!.Trim(), StringComparison.Ordinal));
if (canonicalName is not null && !canonicalName.Equals(returnedFunctionName, StringComparison.Ordinal))
{
logger.LogWarning("Canonicalized tool call function name '{ReturnedFunctionName}' to '{CanonicalFunctionName}'.", returnedFunctionName, canonicalName);
normalizedToolCall = normalizedToolCall with
{
Function = normalizedToolCall.Function! with
{
Name = canonicalName,
},
};
}
preparedToolCalls.Add(new PreparedChatCompletionToolCall(normalizedToolCall, true));
}
return preparedToolCalls;
}
private readonly record struct PreparedChatCompletionToolCall(ChatCompletionToolCall ToolCall, bool IsValid);
}
@@ -0,0 +1,8 @@
namespace AIStudio.Provider.OpenAI;
public sealed record ChatCompletionToolFunction
{
public string? Name { get; init; }
public string? Arguments { get; init; }
}
@@ -0,0 +1,88 @@
using System.Text.Json;
using System.Text.Json.Nodes;
namespace AIStudio.Provider.OpenAI;
/// <summary>
/// Translates a tool's parameter schema into the form OpenAI's strict mode requires.
/// </summary>
/// <remarks>
/// Strict mode does not accept an optional argument the ordinary JSON Schema way. It insists that
/// every property appears in <c>required</c>, and an argument that may be left out has to say so
/// by allowing null instead — <c>"type": ["string", "null"]</c>, and <c>null</c> among its enum
/// values where it has any.<br/><br/>
/// Tool definitions are written the ordinary way, so this converts on the way out. Both forms mean
/// the same to a tool: a null argument and an absent one are treated alike.
/// </remarks>
public static class OpenAIStrictToolSchema
{
private const string NULL_TYPE = "null";
/// <summary>
/// Converts one parameter schema, leaving it untouched when every argument is required
/// anyway.
/// </summary>
public static JsonElement FromToolParameters(JsonElement parameters)
{
if (parameters.ValueKind is not JsonValueKind.Object)
return parameters;
if (JsonNode.Parse(parameters.GetRawText()) is not JsonObject schema)
return parameters;
if (schema["properties"] is not JsonObject properties)
return parameters;
var requiredNames = schema["required"] is JsonArray required
? required.Select(entry => entry?.GetValue<string>()).Where(entry => entry is not null).ToHashSet(StringComparer.Ordinal)
: [];
var optionalPropertyNames = properties
.Select(property => property.Key)
.Where(propertyName => !requiredNames.Contains(propertyName))
.ToList();
if (optionalPropertyNames.Count is 0)
return parameters;
foreach (var propertyName in optionalPropertyNames)
{
if (properties[propertyName] is not JsonObject property)
continue;
AllowNullType(property);
AllowNullEnumValue(property);
}
//
// Every property is required in strict mode. The order follows the properties, so the
// schema stays stable across requests, which prompt caching depends on.
//
schema["required"] = new JsonArray([..properties.Select(property => JsonValue.Create(property.Key))]);
return JsonSerializer.Deserialize<JsonElement>(schema.ToJsonString());
}
private static void AllowNullType(JsonObject property)
{
switch (property["type"])
{
case JsonValue singleType when singleType.TryGetValue<string>(out var typeName) && !typeName.Equals(NULL_TYPE, StringComparison.Ordinal):
property["type"] = new JsonArray(JsonValue.Create(typeName), JsonValue.Create(NULL_TYPE));
break;
case JsonArray types when types.All(entry => entry?.GetValue<string>() != NULL_TYPE):
types.Add(JsonValue.Create(NULL_TYPE));
break;
}
}
private static void AllowNullEnumValue(JsonObject property)
{
// Only where the property restricts its values at all: adding null to an absent enum
// would turn an unrestricted argument into one that may only be null.
if (property["enum"] is not JsonArray enumValues || enumValues.Any(entry => entry is null))
return;
enumValues.Insert(0, null);
}
}
@@ -7,6 +7,9 @@ using System.Text.Json;
using AIStudio.Chat;
using AIStudio.Settings;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.Rust;
using AIStudio.Tools.ToolCallingSystem;
using AIStudio.Tools.ToolCallingSystem.Harness;
namespace AIStudio.Provider.OpenAI;
@@ -16,7 +19,6 @@ namespace AIStudio.Provider.OpenAI;
public sealed class ProviderOpenAI() : BaseProvider(LLMProviders.OPEN_AI, new Uri("https://api.openai.com/v1/"), ExternalHttpTrustPolicy.SYSTEM_TRUST_ONLY, LOGGER)
{
private static readonly ILogger<ProviderOpenAI> LOGGER = Program.LOGGER_FACTORY.CreateLogger<ProviderOpenAI>();
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(ProviderOpenAI).Namespace, nameof(ProviderOpenAI));
#region Implementation of IProvider
@@ -87,8 +89,10 @@ public sealed class ProviderOpenAI() : BaseProvider(LLMProviders.OPEN_AI, new Ur
_ => systemPromptRole,
};
// Read the model capabilities:
var modelCapabilities = this.Provider.GetModelCapabilities(chatModel);
// Read the model capabilities. Through the settings provider, so that the user's expert
// capability overrides apply:
var providerSettings = this.CreateSettingsProvider(chatModel);
var modelCapabilities = providerSettings.GetModelCapabilities();
// Check if we are using the Responses API or the Chat Completion API:
var usingResponsesAPI = modelCapabilities.Contains(Capability.RESPONSES_API);
@@ -98,81 +102,156 @@ public sealed class ProviderOpenAI() : BaseProvider(LLMProviders.OPEN_AI, new Ur
LOGGER.LogInformation("Using the system prompt role '{SystemPromptRole}' and the '{RequestPath}' API for model '{ChatModelId}'.", systemPromptRole, requestPath, chatModel.Id);
// Prepare the system prompt:
var systemPrompt = new TextMessage
{
Role = systemPromptRole,
Content = chatThread.PrepareSystemPrompt(settingsManager),
};
//
// Prepare the tools we want to use:
//
IList<ProviderTool> providerTools = modelCapabilities.Contains(Capability.WEB_SEARCH) switch
{
true => [ ProviderTools.WEB_SEARCH ],
_ => []
};
var toolRegistry = Program.SERVICE_PROVIDER.GetService<ToolRegistry>();
var providerConfidence = this.Provider.GetConfidence(settingsManager).Level;
//
// The provider-native web search is held to the same confidence the local web search tool
// asks for: to the user it is the same act, whoever performs the search.
//
var minimumWebSearchConfidence = toolRegistry?.GetMinimumProviderConfidence(ToolSelectionRules.WEB_SEARCH_TOOL_ID) ?? ConfidenceLevel.NONE;
var isWebSearchAllowed = settingsManager.IsToolActive(ToolSelectionRules.WEB_SEARCH_TOOL_ID) &&
ToolSelectionRules.IsProviderConfidenceAllowed(providerConfidence, minimumWebSearchConfidence);
IList<object> providerTools = modelCapabilities.Contains(Capability.WEB_SEARCH) && isWebSearchAllowed
? [ ProviderTools.WEB_SEARCH ]
: [];
// Parse the API parameters:
var apiParameters = this.ParseAdditionalApiParameters("input", "store", "tools");
var additionalApiParameters = this.ParseAdditionalApiParameters("input", "store", "tools");
if (!usingResponsesAPI)
{
await foreach (var content in this.StreamOpenAICompatibleChatCompletion<ChatCompletionAPIRequest, ChatCompletionDeltaStreamLine, ChatCompletionAnnotationStreamLine>(
"OpenAI",
chatModel,
chatThread,
settingsManager,
async (systemPrompt, apiParameters, tools) =>
{
var messages = await chatThread.Blocks.BuildMessagesAsync(
this.Provider,
chatModel,
role => role switch
{
ChatRole.USER => "user",
ChatRole.AI => "assistant",
ChatRole.AGENT => "assistant",
ChatRole.SYSTEM => systemPromptRole,
_ => "user",
},
text => new SubContentText
{
Text = text,
},
async attachment => new SubContentImageUrlNested
{
ImageUrl = new SubContentImageUrlData
{
Url = await attachment.TryAsBase64(token: token) is (true, var base64Content)
? $"data:{attachment.DetermineMimeType()};base64,{base64Content}"
: string.Empty,
},
});
return new ChatCompletionAPIRequest
{
Model = chatModel.Id,
Messages = [systemPrompt, ..messages],
Stream = true,
Tools = tools,
AdditionalApiParameters = apiParameters,
};
},
systemPromptRole: systemPromptRole,
requestPath: "chat/completions",
token: token))
yield return content;
yield break;
}
var toolExecutor = Program.SERVICE_PROVIDER.GetService<ToolExecutor>();
var currentAssistantContent = chatThread.Blocks.LastOrDefault(x => x.Role is ChatRole.AI)?.Content as ContentText;
currentAssistantContent?.ToolInvocations.Clear();
IReadOnlyList<(ToolDefinition Definition, IToolImplementation Implementation)> runnableTools = toolRegistry is null
? []
: await toolRegistry.GetRunnableToolsAsync(
providerSettings,
chatThread.RuntimeComponent,
chatThread.RuntimeSelectedToolIds,
providerConfidence,
chatThread.MayRunTools(settingsManager));
var toolAwareDefinitions = toolExecutor is null
? Enumerable.Empty<ToolDefinition>()
: runnableTools.Select(x => x.Definition);
var systemPrompt = new TextMessage
{
Role = systemPromptRole,
Content = chatThread.PrepareSystemPrompt(settingsManager, toolAwareDefinitions),
};
// Build the list of messages:
var messages = await chatThread.Blocks.BuildMessagesAsync(
this.Provider, chatModel,
// OpenAI-specific role mapping:
role => role switch
{
ChatRole.USER => "user",
ChatRole.AI => "assistant",
ChatRole.AGENT => "assistant",
ChatRole.SYSTEM => systemPromptRole,
_ => "user",
},
// OpenAI's text sub-content depends on the model, whether we are using
// the Responses API or the Chat Completion API:
text => usingResponsesAPI switch
text => new SubContentInputText
{
// Responses API uses INPUT_TEXT:
true => new SubContentInputText
{
Text = text,
},
// Chat Completion API uses TEXT:
false => new SubContentText
{
Text = text,
},
Text = text,
},
// OpenAI's image sub-content depends on the model as well,
// whether we are using the Responses API or the Chat Completion API:
async attachment => usingResponsesAPI switch
async attachment => new SubContentInputImage
{
// Responses API uses INPUT_IMAGE:
true => new SubContentInputImage
{
ImageUrl = await attachment.TryAsBase64(token: token) is (true, var base64Content)
? $"data:{attachment.DetermineMimeType()};base64,{base64Content}"
: string.Empty,
},
// Chat Completion API uses IMAGE_URL:
false => new SubContentImageUrlNested
{
ImageUrl = new SubContentImageUrlData
{
Url = await attachment.TryAsBase64(token: token) is (true, var base64Content)
? $"data:{attachment.DetermineMimeType()};base64,{base64Content}"
: string.Empty,
},
}
ImageUrl = await attachment.TryAsBase64(token: token) is (true, var base64Content)
? $"data:{attachment.DetermineMimeType()};base64,{base64Content}"
: string.Empty,
});
var baseInput = new List<object> { systemPrompt };
baseInput.AddRange(messages);
if (usingResponsesAPI && toolExecutor is not null && runnableTools.Count > 0)
{
var adapter = new ResponsesToolCallingAdapter(
chatModel,
baseInput,
additionalApiParameters,
providerTools,
runnableTools,
(requestDto, requestToken) => this.ExecuteResponsesRequest(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;
}
if (runnableTools.Count > 0)
providerTools = [];
//
// Create the request: either for the Responses API or the Chat Completion API
@@ -189,16 +268,16 @@ public sealed class ProviderOpenAI() : BaseProvider(LLMProviders.OPEN_AI, new Ur
// Right now, we only support streaming completions:
Stream = true,
AdditionalApiParameters = apiParameters
AdditionalApiParameters = additionalApiParameters
}, JSON_SERIALIZER_OPTIONS),
// Responses API request:
true => JsonSerializer.Serialize(new ResponsesAPIRequest
{
Model = chatModel.Id,
// All messages go into the input field:
Input = [systemPrompt, ..messages],
Input = baseInput,
// Right now, we only support streaming completions:
Stream = true,
@@ -207,10 +286,10 @@ public sealed class ProviderOpenAI() : BaseProvider(LLMProviders.OPEN_AI, new Ur
Store = false,
// Tools we want to use:
ProviderTools = providerTools,
Tools = providerTools,
// Additional API parameters:
AdditionalApiParameters = apiParameters
AdditionalApiParameters = additionalApiParameters
}, JSON_SERIALIZER_OPTIONS),
};
@@ -237,6 +316,24 @@ public sealed class ProviderOpenAI() : BaseProvider(LLMProviders.OPEN_AI, new Ur
yield return content;
}
private async Task<ResponsesResponse?> ExecuteResponsesRequest(ResponsesAPIRequest requestDto, RequestedSecret requestedSecret, CancellationToken token)
{
using var request = new HttpRequestMessage(HttpMethod.Post, "responses");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await requestedSecret.Secret.Decrypt(Program.ENCRYPTION));
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 Responses API 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<ResponsesResponse>(JSON_SERIALIZER_OPTIONS, token);
}
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
/// <inheritdoc />
@@ -271,37 +368,36 @@ public sealed class ProviderOpenAI() : BaseProvider(LLMProviders.OPEN_AI, new Ur
/// <inheritdoc />
public override Task<ModelLoadResult> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default)
{
return this.LoadModels(SecretStoreType.LLM_PROVIDER, static model => model.IsChatModel(), token, apiKeyProvisional);
return this.LoadModels(SecretStoreType.LLM_PROVIDER, static model => model.IsChatModel(), apiKeyProvisional, token);
}
/// <inheritdoc />
public override Task<ModelLoadResult> GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default)
{
return this.LoadModels(SecretStoreType.IMAGE_PROVIDER, static model => model.IsImageModel(), token, apiKeyProvisional);
return this.LoadModels(SecretStoreType.IMAGE_PROVIDER, static model => model.IsImageModel(), apiKeyProvisional, token);
}
/// <inheritdoc />
public override Task<ModelLoadResult> GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default)
{
return this.LoadModels(SecretStoreType.EMBEDDING_PROVIDER, static model => model.IsEmbeddingModel(), token, apiKeyProvisional);
return this.LoadModels(SecretStoreType.EMBEDDING_PROVIDER, static model => model.IsEmbeddingModel(), apiKeyProvisional, token);
}
/// <inheritdoc />
public override Task<ModelLoadResult> GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default)
{
return this.LoadModels(SecretStoreType.TRANSCRIPTION_PROVIDER, static model => model.IsTranscriptionModel(), token, apiKeyProvisional);
return this.LoadModels(SecretStoreType.TRANSCRIPTION_PROVIDER, static model => model.IsTranscriptionModel(), apiKeyProvisional, token);
}
#endregion
private Task<ModelLoadResult> LoadModels(SecretStoreType storeType, Func<Model, bool> isWantedKind, CancellationToken token, string? apiKeyProvisional = null)
private Task<ModelLoadResult> LoadModels(SecretStoreType storeType, Func<Model, bool> isWantedKind, string? apiKeyProvisional, CancellationToken token)
{
return this.LoadModelsResponse<ModelsResponse>(
storeType,
"models",
modelResponse => modelResponse.Data.Where(isWantedKind),
token,
apiKeyProvisional);
apiKeyProvisional, token: token);
}
private static bool HasInsufficientQuotaError(string responseBody)
@@ -358,4 +454,4 @@ public sealed class ProviderOpenAI() : BaseProvider(LLMProviders.OPEN_AI, new Ur
propertyElement.ValueKind is JsonValueKind.String &&
string.Equals(propertyElement.GetString(), expectedValue, StringComparison.OrdinalIgnoreCase);
}
}
}
@@ -6,16 +6,16 @@ namespace AIStudio.Provider.OpenAI;
/// The request body for the Responses API.
/// </summary>
/// <param name="Model">Which model to use.</param>
/// <param name="Input">The chat messages.</param>
/// <param name="Input">The chat messages and Responses API input items.</param>
/// <param name="Stream">Whether to stream the response.</param>
/// <param name="Store">Whether to store the response on the server (usually OpenAI's infrastructure).</param>
/// <param name="ProviderTools">The provider-side tools to use for the request.</param>
/// <param name="Tools">The provider-side tools and local function tools to use for the request.</param>
public record ResponsesAPIRequest(
string Model,
IList<IMessageBase> Input,
IList<object> Input,
bool Stream,
bool Store,
[property: JsonPropertyName("tools")] IList<ProviderTool> ProviderTools)
IList<object> Tools)
{
public ResponsesAPIRequest() : this(string.Empty, [], true, false, [])
{
@@ -0,0 +1,15 @@
namespace AIStudio.Provider.OpenAI;
/// <summary>
/// A function call item returned by the OpenAI Responses API.
/// </summary>
public sealed record ResponsesFunctionCallItem
{
public string? Type { get; init; }
public string? CallId { get; init; }
public string? Name { get; init; }
public string? Arguments { get; init; }
}
@@ -0,0 +1,13 @@
namespace AIStudio.Provider.OpenAI;
/// <summary>
/// A local function result item sent back to the OpenAI Responses API.
/// </summary>
public sealed record ResponsesFunctionCallOutputItem
{
public string Type { get; init; } = "function_call_output";
public string CallId { get; init; } = string.Empty;
public string Output { get; init; } = string.Empty;
}
@@ -0,0 +1,19 @@
using System.Text.Json;
namespace AIStudio.Provider.OpenAI;
/// <summary>
/// The flat function tool definition shape expected by the OpenAI Responses API.
/// </summary>
public sealed record ResponsesFunctionTool
{
public string Type { get; init; } = "function";
public string Name { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
public JsonElement Parameters { get; init; }
public bool Strict { get; init; }
}
@@ -0,0 +1,77 @@
using System.Text.Json;
namespace AIStudio.Provider.OpenAI;
/// <summary>
/// Non-streaming OpenAI Responses API result used during local tool execution.
/// </summary>
public sealed record ResponsesResponse
{
public string Id { get; init; } = string.Empty;
public string Model { get; init; } = string.Empty;
public string? OutputText { get; init; }
public IList<JsonElement> Output { get; init; } = [];
public IReadOnlyList<ResponsesFunctionCallItem> GetFunctionCalls() => this.Output
.Where(x => ReadString(x, "type").Equals("function_call", StringComparison.Ordinal))
.Select(x => new ResponsesFunctionCallItem
{
Type = ReadString(x, "type"),
CallId = ReadString(x, "call_id"),
Name = ReadString(x, "name"),
Arguments = ReadString(x, "arguments"),
})
.ToList();
public string GetTextOutput()
{
if (!string.IsNullOrWhiteSpace(this.OutputText))
return this.OutputText;
return string.Concat(this.Output
.Where(x => ReadString(x, "type").Equals("message", StringComparison.Ordinal))
.SelectMany(ReadContentItems)
.Select(x => ReadString(x, "type") switch
{
"output_text" => ReadString(x, "text"),
"refusal" => ReadString(x, "refusal"),
_ => string.Empty,
}));
}
public IReadOnlyList<Source> GetSources() => this.Output
.Where(x => ReadString(x, "type").Equals("message", StringComparison.Ordinal))
.SelectMany(ReadContentItems)
.SelectMany(x => ReadArrayItems(x, "annotations"))
.Where(x => ReadString(x, "type").Equals("url_citation", StringComparison.Ordinal))
.Select(x => new Source(ReadString(x, "title"), ReadString(x, "url"), SourceOrigin.LLM))
.Where(x => !string.IsNullOrWhiteSpace(x.Title) && !string.IsNullOrWhiteSpace(x.URL))
.ToList();
private static IEnumerable<JsonElement> ReadContentItems(JsonElement outputItem)
=> ReadArrayItems(outputItem, "content");
private static IEnumerable<JsonElement> ReadArrayItems(JsonElement item, string propertyName)
{
if (item.ValueKind is not JsonValueKind.Object ||
!item.TryGetProperty(propertyName, out var array) ||
array.ValueKind is not JsonValueKind.Array)
yield break;
foreach (var arrayItem in array.EnumerateArray())
yield return arrayItem;
}
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,105 @@
using AIStudio.Tools.ToolCallingSystem;
using AIStudio.Tools.ToolCallingSystem.Harness;
namespace AIStudio.Provider.OpenAI;
/// <summary>
/// Speaks the OpenAI Responses wire format for the tool calling loop.
/// </summary>
/// <remarks>
/// Function calls arrive as output items and results go back as function call output items,
/// correlated by call ID. Unlike Chat Completions, the whole output of a round has to be sent
/// back for the next one, reasoning items included, or the API refuses to continue.
/// </remarks>
public sealed class ResponsesToolCallingAdapter(Model chatModel, IList<object> baseInput, IDictionary<string, object> apiParameters, IList<object> providerTools,
IReadOnlyList<(ToolDefinition Definition, IToolImplementation Implementation)> runnableTools,
Func<ResponsesAPIRequest, CancellationToken, Task<ResponsesResponse?>> executeRequestAsync) : IToolCallingProviderAdapter
{
private readonly List<object> internalItems = [];
private ResponsesResponse? lastResponse;
/// <summary>
/// The tools offered to the model: the provider-native ones plus our local functions.
/// </summary>
/// <remarks>
/// A provider-native tool whose type collides with one of our function names is dropped
/// because the model could not tell the two apart.
/// </remarks>
private readonly IList<object> effectiveProviderTools = BuildEffectiveProviderTools(providerTools, runnableTools);
/// <inheritdoc />
public async Task<ToolCallingRound?> ExecuteRoundAsync(string? finalResponseInstruction, bool includeTools, CancellationToken token = default)
{
var requestInput = new List<object>(baseInput);
if (finalResponseInstruction is not null && requestInput.FirstOrDefault() is TextMessage systemPrompt)
{
requestInput[0] = systemPrompt with
{
Content = $"{systemPrompt.Content}{Environment.NewLine}{Environment.NewLine}{finalResponseInstruction}",
};
}
requestInput.AddRange(this.internalItems);
var response = await executeRequestAsync(new ResponsesAPIRequest
{
Model = chatModel.Id,
Input = requestInput,
Stream = false,
Store = false,
Tools = includeTools ? this.effectiveProviderTools : [],
AdditionalApiParameters = apiParameters,
}, token);
if (response is null)
return null;
this.lastResponse = response;
return new ToolCallingRound(
response.GetTextOutput(),
response.GetFunctionCalls()
.Select(call => new ToolCallingRequestedCall(
call.CallId ?? string.Empty,
call.Name ?? string.Empty,
call.Arguments ?? string.Empty,
!string.IsNullOrWhiteSpace(call.Name) && ToolExecutor.IsValidArgumentsJson(call.Arguments)))
.ToList(),
response.GetSources());
}
/// <inheritdoc />
public void RecordAssistantTurn()
{
if (this.lastResponse is null)
return;
// Every output item, not just the function calls: the API rejects a continuation whose
// reasoning items are missing.
foreach (var outputItem in this.lastResponse.Output)
this.internalItems.Add(outputItem);
}
/// <inheritdoc />
/// <remarks>
/// The Responses API has no error flag on a function call output, so a failure travels in the
/// output like any other result.
/// </remarks>
public void RecordToolResult(string callId, string content, bool isError = false) => this.internalItems.Add(new ResponsesFunctionCallOutputItem
{
CallId = callId,
Output = content,
});
private static IList<object> BuildEffectiveProviderTools(IList<object> providerTools, IReadOnlyList<(ToolDefinition Definition, IToolImplementation Implementation)> runnableTools)
{
var localFunctionNames = runnableTools
.Select(x => x.Definition.Function.Name)
.ToHashSet(StringComparer.Ordinal);
return providerTools
.Where(x => x is not ProviderTool providerTool || !localFunctionNames.Contains(providerTool.Type))
.Concat(runnableTools.Select(x => (object)ProviderToolAdapters.ToResponsesTool(x.Definition)))
.ToList();
}
}
@@ -0,0 +1,10 @@
namespace AIStudio.Provider.OpenAI;
public sealed record ToolResultMessage : IMessage<string>
{
public string Role { get; init; } = "tool";
public string Content { get; init; } = string.Empty;
public string ToolCallId { get; init; } = string.Empty;
}
@@ -33,7 +33,7 @@ public sealed class ProviderOpenRouter() : BaseProvider(LLMProviders.OPEN_ROUTER
chatModel,
chatThread,
settingsManager,
async (systemPrompt, apiParameters) =>
async (systemPrompt, apiParameters, tools) =>
{
// Build the list of messages:
var messages = await chatThread.Blocks.BuildMessagesUsingNestedImageUrlAsync(this.Provider, chatModel);
@@ -49,6 +49,7 @@ public sealed class ProviderOpenRouter() : BaseProvider(LLMProviders.OPEN_ROUTER
// Right now, we only support streaming completions:
Stream = true,
Tools = tools,
AdditionalApiParameters = apiParameters
};
},
@@ -86,7 +87,7 @@ public sealed class ProviderOpenRouter() : BaseProvider(LLMProviders.OPEN_ROUTER
/// <inheritdoc />
public override Task<ModelLoadResult> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default)
{
return this.LoadModels(SecretStoreType.LLM_PROVIDER, token, apiKeyProvisional);
return this.LoadModels(SecretStoreType.LLM_PROVIDER, apiKeyProvisional, token);
}
/// <inheritdoc />
@@ -98,7 +99,7 @@ public sealed class ProviderOpenRouter() : BaseProvider(LLMProviders.OPEN_ROUTER
/// <inheritdoc />
public override Task<ModelLoadResult> GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default)
{
return this.LoadEmbeddingModels(token, apiKeyProvisional);
return this.LoadEmbeddingModels(apiKeyProvisional, token);
}
/// <inheritdoc />
@@ -109,7 +110,7 @@ public sealed class ProviderOpenRouter() : BaseProvider(LLMProviders.OPEN_ROUTER
#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<OpenRouterModelsResponse>(
storeType,
@@ -117,29 +118,29 @@ public sealed class ProviderOpenRouter() : BaseProvider(LLMProviders.OPEN_ROUTER
modelResponse => modelResponse.Data
.Select(n => new Model(n.Id, n.Name))
.Where(model => model.IsChatModel()),
token,
apiKeyProvisional,
requestConfigurator: (request, secretKey) =>
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", secretKey);
request.Headers.Add("HTTP-Referer", PROJECT_WEBSITE);
request.Headers.Add("X-Title", PROJECT_NAME);
});
},
token: token);
}
private Task<ModelLoadResult> LoadEmbeddingModels(CancellationToken token, string? apiKeyProvisional = null)
private Task<ModelLoadResult> LoadEmbeddingModels(string? apiKeyProvisional, CancellationToken token)
{
return this.LoadModelsResponse<OpenRouterModelsResponse>(
SecretStoreType.EMBEDDING_PROVIDER,
"embeddings/models",
modelResponse => modelResponse.Data.Select(n => new Model(n.Id, n.Name)),
token,
apiKeyProvisional,
requestConfigurator: (request, secretKey) =>
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", secretKey);
request.Headers.Add("HTTP-Referer", PROJECT_WEBSITE);
request.Headers.Add("X-Title", PROJECT_NAME);
});
},
token: token);
}
}
}
@@ -38,7 +38,7 @@ public sealed class ProviderPerplexity() : BaseProvider(LLMProviders.PERPLEXITY,
chatModel,
chatThread,
settingsManager,
async (systemPrompt, apiParameters) =>
async (systemPrompt, apiParameters, tools) =>
{
// Build the list of messages:
var messages = await chatThread.Blocks.BuildMessagesUsingNestedImageUrlAsync(this.Provider, chatModel);
@@ -52,6 +52,7 @@ public sealed class ProviderPerplexity() : BaseProvider(LLMProviders.PERPLEXITY,
// - Then none-empty user and AI messages
Messages = [systemPrompt, ..messages],
Stream = true,
Tools = tools,
AdditionalApiParameters = apiParameters
};
},
@@ -106,4 +107,4 @@ public sealed class ProviderPerplexity() : BaseProvider(LLMProviders.PERPLEXITY,
#endregion
private Task<ModelLoadResult> LoadModels() => Task.FromResult(ModelLoadResult.FromModels(KNOWN_MODELS));
}
}
@@ -0,0 +1,65 @@
using AIStudio.Provider.Anthropic;
using AIStudio.Provider.OpenAI;
using AIStudio.Tools.ToolCallingSystem;
namespace AIStudio.Provider;
/// <summary>
/// Converts a tool definition into the wire shape one provider API expects.
/// </summary>
/// <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.
/// </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
{
type = "function",
function = new
{
name = definition.Function.Name,
description = definition.Function.DescriptionForLLM,
parameters = ToOpenAIParameters(definition),
strict = definition.Function.Strict,
}
};
/// <summary>
/// Builds the flat function tool shape used by the OpenAI Responses API.
/// </summary>
public static ResponsesFunctionTool ToResponsesTool(ToolDefinition definition) => new()
{
Name = definition.Function.Name,
Description = definition.Function.DescriptionForLLM,
Parameters = ToOpenAIParameters(definition),
Strict = definition.Function.Strict,
};
/// <summary>
/// Builds the tool shape used by the Anthropic messages API.
/// </summary>
/// <remarks>
/// Different field names — Anthropic calls the parameters an input schema and takes the
/// description without nesting it under a function object — but the schema itself needs no
/// conversion: Anthropic reads optionality the same way the definitions write it.
/// </remarks>
public static AnthropicTool ToAnthropicTool(ToolDefinition definition) => new()
{
Name = definition.Function.Name,
Description = definition.Function.DescriptionForLLM,
InputSchema = definition.Function.Parameters,
Strict = definition.Function.Strict,
};
/// <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
? OpenAIStrictToolSchema.FromToolParameters(definition.Function.Parameters)
: definition.Function.Parameters;
}
@@ -0,0 +1,3 @@
namespace AIStudio.Provider.SelfHosted;
public readonly record struct Model(string Id, string? Object, string? OwnedBy, ModelArchitecture? Architecture);
@@ -0,0 +1,3 @@
namespace AIStudio.Provider.SelfHosted;
public readonly record struct ModelArchitecture(string[]? InputModalities, string[]? OutputModalities);
@@ -1,7 +1,3 @@
namespace AIStudio.Provider.SelfHosted;
public readonly record struct ModelsResponse(string? Object, Model[]? Data);
public readonly record struct Model(string Id, string? Object, string? OwnedBy, ModelArchitecture? Architecture);
public readonly record struct ModelArchitecture(string[]? InputModalities, string[]? OutputModalities);
public readonly record struct ModelsResponse(string? Object, Model[]? Data);
@@ -35,7 +35,7 @@ public sealed class ProviderSelfHosted(Host host, string hostname) : BaseProvide
effectiveChatModel,
chatThread,
settingsManager,
async (systemPrompt, apiParameters) =>
async (systemPrompt, apiParameters, tools) =>
{
// Build the list of messages. The image format depends on the host:
// - Ollama uses the direct image URL format: { "type": "image_url", "image_url": "data:..." }
@@ -57,6 +57,7 @@ public sealed class ProviderSelfHosted(Host host, string hostname) : BaseProvide
// Right now, we only support streaming completions:
Stream = true,
Tools = tools,
AdditionalApiParameters = apiParameters
};
},
@@ -95,12 +96,12 @@ public sealed class ProviderSelfHosted(Host host, string hostname) : BaseProvide
switch (host)
{
case Host.LLAMA_CPP:
return await this.LoadLlamaCppTextModels(["embed"], [], token, apiKeyProvisional);
return await this.LoadLlamaCppTextModels(["embed"], [], apiKeyProvisional, token);
case Host.LM_STUDIO:
case Host.OLLAMA:
case Host.VLLM:
return await this.LoadModels( SecretStoreType.LLM_PROVIDER, ["embed"], [], token, apiKeyProvisional);
return await this.LoadModels( SecretStoreType.LLM_PROVIDER, ["embed"], [], apiKeyProvisional, token);
}
return ModelLoadResult.FromModels([]);
@@ -127,7 +128,7 @@ public sealed class ProviderSelfHosted(Host host, string hostname) : BaseProvide
case Host.LM_STUDIO:
case Host.OLLAMA:
case Host.VLLM:
return await this.LoadModels( SecretStoreType.EMBEDDING_PROVIDER, [], ["embed"], token, apiKeyProvisional);
return await this.LoadModels( SecretStoreType.EMBEDDING_PROVIDER, [], ["embed"], apiKeyProvisional, token);
}
return ModelLoadResult.FromModels([]);
@@ -154,7 +155,7 @@ public sealed class ProviderSelfHosted(Host host, string hostname) : BaseProvide
case Host.OLLAMA:
case Host.VLLM:
return await this.LoadModels(SecretStoreType.TRANSCRIPTION_PROVIDER, [], [], token, apiKeyProvisional);
return await this.LoadModels(SecretStoreType.TRANSCRIPTION_PROVIDER, [], [], apiKeyProvisional, token);
default:
return ModelLoadResult.FromModels([]);
@@ -169,7 +170,7 @@ public sealed class ProviderSelfHosted(Host host, string hostname) : BaseProvide
#endregion
private async Task<ModelLoadResult> LoadModels(SecretStoreType storeType, string[] ignorePhrases, string[] filterPhrases, CancellationToken token, string? apiKeyProvisional = null)
private async Task<ModelLoadResult> LoadModels(SecretStoreType storeType, string[] ignorePhrases, string[] filterPhrases, string? apiKeyProvisional, CancellationToken token)
{
var secretKey = await this.GetModelLoadingSecretKey(storeType, apiKeyProvisional, true);
@@ -202,13 +203,12 @@ public sealed class ProviderSelfHosted(Host host, string hostname) : BaseProvide
return FailedModelLoadResult(ModelLoadFailureReason.PROVIDER_UNAVAILABLE, e.Message);
}
}
private async Task<Provider.Model> ResolveChatModelForRequest(Provider.Model chatModel, CancellationToken token)
{
if (host is not Host.LLAMA_CPP || !chatModel.IsSystemModel)
return chatModel;
var modelLoadResult = await this.LoadLlamaCppTextModels(["embed"], [], token);
var modelLoadResult = await this.LoadLlamaCppTextModels(["embed"], [], null, token);
if (!modelLoadResult.Success)
return chatModel;
@@ -245,7 +245,7 @@ public sealed class ProviderSelfHosted(Host host, string hostname) : BaseProvide
return chatModel;
}
private async Task<ModelLoadResult> LoadLlamaCppTextModels(string[] ignorePhrases, string[] filterPhrases, CancellationToken token, string? apiKeyProvisional = null)
private async Task<ModelLoadResult> LoadLlamaCppTextModels(string[] ignorePhrases, string[] filterPhrases, string? apiKeyProvisional, CancellationToken token)
{
var secretKey = await this.GetModelLoadingSecretKey(SecretStoreType.LLM_PROVIDER, apiKeyProvisional, true);
@@ -325,4 +325,4 @@ public sealed class ProviderSelfHosted(Host host, string hostname) : BaseProvide
{
return ModelLoadResult.FromModels([ AIStudio.Provider.Model.SYSTEM_MODEL ]);
}
}
}
@@ -29,7 +29,7 @@ public sealed class ProviderX() : BaseProvider(LLMProviders.X, new Uri("https://
chatModel,
chatThread,
settingsManager,
async (systemPrompt, apiParameters) =>
async (systemPrompt, apiParameters, tools) =>
{
// Build the list of messages:
var messages = await chatThread.Blocks.BuildMessagesUsingNestedImageUrlAsync(this.Provider, chatModel);
@@ -45,6 +45,7 @@ public sealed class ProviderX() : BaseProvider(LLMProviders.X, new Uri("https://
// Right now, we only support streaming completions:
Stream = true,
Tools = tools,
AdditionalApiParameters = apiParameters
};
},
@@ -75,7 +76,7 @@ public sealed class ProviderX() : BaseProvider(LLMProviders.X, new Uri("https://
/// <inheritdoc />
public override async Task<ModelLoadResult> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default)
{
var result = await this.LoadModels(SecretStoreType.LLM_PROVIDER, ["grok-"], token, apiKeyProvisional);
var result = await this.LoadModels(SecretStoreType.LLM_PROVIDER, ["grok-"], apiKeyProvisional, token);
return result with
{
Models = [..result.Models.Where(n => !n.Id.Contains("-image", StringComparison.OrdinalIgnoreCase))]
@@ -102,7 +103,7 @@ public sealed class ProviderX() : BaseProvider(LLMProviders.X, new Uri("https://
#endregion
private Task<ModelLoadResult> LoadModels(SecretStoreType storeType, string[] prefixes, CancellationToken token, string? apiKeyProvisional = null)
private Task<ModelLoadResult> LoadModels(SecretStoreType storeType, string[] prefixes, string? apiKeyProvisional, CancellationToken token)
{
return this.LoadModelsResponse<ModelsResponse>(
storeType,
@@ -115,7 +116,6 @@ public sealed class ProviderX() : BaseProvider(LLMProviders.X, new Uri("https://
DisplayName = "Grok 2.0 (latest)",
}
]),
token,
apiKeyProvisional);
apiKeyProvisional, token: token);
}
}