mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-13 16:34:24 +00:00
Added tool calling support (#731)
Co-authored-by: krut_ni <nils.kruthoff@dlr.de> Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
This commit is contained in:
266 files changed
+11186
-740
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user