From d494fe4bc72135bd6abe968510f1736d2dc8616d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peer=20Sch=C3=BCtt?= Date: Mon, 13 Apr 2026 13:33:17 +0200 Subject: [PATCH] Chat request refactoring for OpenAI-compatible providers (#722) --- .../AlibabaCloud/ProviderAlibabaCloud.cs | 68 +++++---------- .../Provider/BaseProvider.cs | 72 ++++++++++++++++ .../Provider/DeepSeek/ProviderDeepSeek.cs | 68 +++++---------- .../Provider/Fireworks/ChatRequest.cs | 20 ----- .../Provider/Fireworks/ProviderFireworks.cs | 71 +++++----------- .../Provider/GWDG/ProviderGWDG.cs | 68 +++++---------- .../Provider/Google/ChatRequest.cs | 20 ----- .../Provider/Google/ProviderGoogle.cs | 68 +++++---------- .../Provider/Groq/ChatRequest.cs | 22 ----- .../Provider/Groq/ProviderGroq.cs | 71 ++++++---------- .../Provider/Helmholtz/ProviderHelmholtz.cs | 68 +++++---------- .../HuggingFace/ProviderHuggingFace.cs | 71 +++++----------- .../Provider/Mistral/ChatRequest.cs | 25 ------ .../Provider/Mistral/ProviderMistral.cs | 75 ++++++----------- .../Provider/OpenAI/ProviderOpenAI.cs | 8 +- .../OpenAI/{Tool.cs => ProviderTool.cs} | 4 +- .../OpenAI/{Tools.cs => ProviderTools.cs} | 8 +- .../Provider/OpenAI/ResponsesAPIRequest.cs | 6 +- .../Provider/OpenRouter/ProviderOpenRouter.cs | 78 +++++++---------- .../Provider/Perplexity/ProviderPerplexity.cs | 69 +++++---------- .../Provider/SelfHosted/ChatRequest.cs | 20 ----- .../Provider/SelfHosted/ProviderSelfHosted.cs | 83 +++++++------------ .../Provider/X/ProviderX.cs | 70 +++++----------- .../wwwroot/changelog/v26.3.1.md | 1 + 24 files changed, 398 insertions(+), 736 deletions(-) delete mode 100644 app/MindWork AI Studio/Provider/Fireworks/ChatRequest.cs delete mode 100644 app/MindWork AI Studio/Provider/Google/ChatRequest.cs delete mode 100644 app/MindWork AI Studio/Provider/Groq/ChatRequest.cs delete mode 100644 app/MindWork AI Studio/Provider/Mistral/ChatRequest.cs rename app/MindWork AI Studio/Provider/OpenAI/{Tool.cs => ProviderTool.cs} (79%) rename app/MindWork AI Studio/Provider/OpenAI/{Tools.cs => ProviderTools.cs} (67%) delete mode 100644 app/MindWork AI Studio/Provider/SelfHosted/ChatRequest.cs diff --git a/app/MindWork AI Studio/Provider/AlibabaCloud/ProviderAlibabaCloud.cs b/app/MindWork AI Studio/Provider/AlibabaCloud/ProviderAlibabaCloud.cs index 3535809d..7f2bf792 100644 --- a/app/MindWork AI Studio/Provider/AlibabaCloud/ProviderAlibabaCloud.cs +++ b/app/MindWork AI Studio/Provider/AlibabaCloud/ProviderAlibabaCloud.cs @@ -1,7 +1,5 @@ using System.Net.Http.Headers; using System.Runtime.CompilerServices; -using System.Text; -using System.Text.Json; using AIStudio.Chat; using AIStudio.Provider.OpenAI; @@ -24,52 +22,30 @@ public sealed class ProviderAlibabaCloud() : BaseProvider(LLMProviders.ALIBABA_C /// public override async IAsyncEnumerable StreamChatCompletion(Model chatModel, ChatThread chatThread, SettingsManager settingsManager, [EnumeratorCancellation] CancellationToken token = default) { - // Get the API key: - var requestedSecret = await RUST_SERVICE.GetAPIKey(this, SecretStoreType.LLM_PROVIDER); - if(!requestedSecret.Success) - yield break; - - // Prepare the system prompt: - var systemPrompt = new TextMessage - { - Role = "system", - Content = chatThread.PrepareSystemPrompt(settingsManager), - }; - - // Parse the API parameters: - var apiParameters = this.ParseAdditionalApiParameters(); - - // Build the list of messages: - var messages = await chatThread.Blocks.BuildMessagesUsingNestedImageUrlAsync(this.Provider, chatModel); - - // Prepare the AlibabaCloud HTTP chat request: - var alibabaCloudChatRequest = JsonSerializer.Serialize(new ChatCompletionAPIRequest - { - Model = chatModel.Id, - - // Build the messages: - // - First of all the system prompt - // - Then none-empty user and AI messages - Messages = [systemPrompt, ..messages], - - Stream = true, - AdditionalApiParameters = apiParameters - }, JSON_SERIALIZER_OPTIONS); + await foreach (var content in this.StreamOpenAICompatibleChatCompletion( + "AlibabaCloud", + chatModel, + chatThread, + settingsManager, + async (systemPrompt, apiParameters) => + { + // Build the list of messages: + var messages = await chatThread.Blocks.BuildMessagesUsingNestedImageUrlAsync(this.Provider, chatModel); - async Task RequestBuilder() - { - // Build the HTTP post request: - var request = new HttpRequestMessage(HttpMethod.Post, "chat/completions"); + return new ChatCompletionAPIRequest + { + Model = chatModel.Id, - // Set the authorization header: - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await requestedSecret.Secret.Decrypt(ENCRYPTION)); + // Build the messages: + // - First of all the system prompt + // - Then none-empty user and AI messages + Messages = [systemPrompt, ..messages], - // Set the content: - request.Content = new StringContent(alibabaCloudChatRequest, Encoding.UTF8, "application/json"); - return request; - } - - await foreach (var content in this.StreamChatCompletionInternal("AlibabaCloud", RequestBuilder, token)) + Stream = true, + AdditionalApiParameters = apiParameters + }; + }, + token: token)) yield return content; } @@ -183,4 +159,4 @@ public sealed class ProviderAlibabaCloud() : BaseProvider(LLMProviders.ALIBABA_C return modelResponse.Data.Where(model => prefixes.Any(prefix => model.Id.StartsWith(prefix, StringComparison.InvariantCulture))); } -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Provider/BaseProvider.cs b/app/MindWork AI Studio/Provider/BaseProvider.cs index 9b729824..46e43843 100644 --- a/app/MindWork AI Studio/Provider/BaseProvider.cs +++ b/app/MindWork AI Studio/Provider/BaseProvider.cs @@ -565,6 +565,78 @@ public abstract class BaseProvider : IProvider, ISecretId streamReader.Dispose(); } + /// + /// Streams the chat completion from an OpenAI-compatible provider using the Chat Completion API. + /// + /// The provider name for logging and error reporting. + /// The selected chat model. + /// The current chat thread. + /// The settings manager. + /// Builds the provider-specific request body. + /// The secret store type. + /// Whether the API key is optional. + /// The system prompt role to use. + /// The request path, relative to the provider base URL. + /// Optional additional headers to add. + /// The cancellation token. + /// The request DTO type. + /// The delta stream line type. + /// The annotation stream line type. + /// The streamed content chunks. + protected async IAsyncEnumerable StreamOpenAICompatibleChatCompletion( + string providerName, + Model chatModel, + ChatThread chatThread, + SettingsManager settingsManager, + Func, Task> requestFactory, + SecretStoreType storeType = SecretStoreType.LLM_PROVIDER, + bool isTryingSecret = false, + string systemPromptRole = "system", + string requestPath = "chat/completions", + Action? headersAction = null, + [EnumeratorCancellation] CancellationToken token = default) + where TDelta : IResponseStreamLine + where TAnnotation : IAnnotationStreamLine + { + // Get the API key: + var requestedSecret = await RUST_SERVICE.GetAPIKey(this, storeType, isTrying: isTryingSecret); + 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(); + + // Prepare the provider HTTP chat request: + var providerChatRequest = JsonSerializer.Serialize(await requestFactory(systemPrompt, apiParameters), JSON_SERIALIZER_OPTIONS); + + async Task RequestBuilder() + { + // Build the HTTP post request: + var request = new HttpRequestMessage(HttpMethod.Post, requestPath); + + // Set the authorization header: + if (requestedSecret.Success) + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await requestedSecret.Secret.Decrypt(ENCRYPTION)); + + // Set provider-specific headers: + headersAction?.Invoke(request.Headers); + + // Set the content: + request.Content = new StringContent(providerChatRequest, Encoding.UTF8, "application/json"); + return request; + } + + await foreach (var content in this.StreamChatCompletionInternal(providerName, RequestBuilder, token)) + yield return content; + } + protected async Task PerformStandardTranscriptionRequest(RequestedSecret requestedSecret, Model transcriptionModel, string audioFilePath, Host host = Host.NONE, CancellationToken token = default) { try diff --git a/app/MindWork AI Studio/Provider/DeepSeek/ProviderDeepSeek.cs b/app/MindWork AI Studio/Provider/DeepSeek/ProviderDeepSeek.cs index e1ae306a..bc1e0806 100644 --- a/app/MindWork AI Studio/Provider/DeepSeek/ProviderDeepSeek.cs +++ b/app/MindWork AI Studio/Provider/DeepSeek/ProviderDeepSeek.cs @@ -1,7 +1,5 @@ using System.Net.Http.Headers; using System.Runtime.CompilerServices; -using System.Text; -using System.Text.Json; using AIStudio.Chat; using AIStudio.Provider.OpenAI; @@ -24,52 +22,30 @@ public sealed class ProviderDeepSeek() : BaseProvider(LLMProviders.DEEP_SEEK, "h /// public override async IAsyncEnumerable StreamChatCompletion(Model chatModel, ChatThread chatThread, SettingsManager settingsManager, [EnumeratorCancellation] CancellationToken token = default) { - // Get the API key: - var requestedSecret = await RUST_SERVICE.GetAPIKey(this, SecretStoreType.LLM_PROVIDER); - if(!requestedSecret.Success) - yield break; - - // Prepare the system prompt: - var systemPrompt = new TextMessage - { - Role = "system", - Content = chatThread.PrepareSystemPrompt(settingsManager), - }; - - // Parse the API parameters: - var apiParameters = this.ParseAdditionalApiParameters(); - - // Build the list of messages: - var messages = await chatThread.Blocks.BuildMessagesUsingDirectImageUrlAsync(this.Provider, chatModel); - - // Prepare the DeepSeek HTTP chat request: - var deepSeekChatRequest = JsonSerializer.Serialize(new ChatCompletionAPIRequest - { - Model = chatModel.Id, - - // Build the messages: - // - First of all the system prompt - // - Then none-empty user and AI messages - Messages = [systemPrompt, ..messages], - - Stream = true, - AdditionalApiParameters = apiParameters - }, JSON_SERIALIZER_OPTIONS); + await foreach (var content in this.StreamOpenAICompatibleChatCompletion( + "DeepSeek", + chatModel, + chatThread, + settingsManager, + async (systemPrompt, apiParameters) => + { + // Build the list of messages: + var messages = await chatThread.Blocks.BuildMessagesUsingDirectImageUrlAsync(this.Provider, chatModel); - async Task RequestBuilder() - { - // Build the HTTP post request: - var request = new HttpRequestMessage(HttpMethod.Post, "chat/completions"); + return new ChatCompletionAPIRequest + { + Model = chatModel.Id, - // Set the authorization header: - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await requestedSecret.Secret.Decrypt(ENCRYPTION)); + // Build the messages: + // - First of all the system prompt + // - Then none-empty user and AI messages + Messages = [systemPrompt, ..messages], - // Set the content: - request.Content = new StringContent(deepSeekChatRequest, Encoding.UTF8, "application/json"); - return request; - } - - await foreach (var content in this.StreamChatCompletionInternal("DeepSeek", RequestBuilder, token)) + Stream = true, + AdditionalApiParameters = apiParameters + }; + }, + token: token)) yield return content; } @@ -144,4 +120,4 @@ public sealed class ProviderDeepSeek() : BaseProvider(LLMProviders.DEEP_SEEK, "h var modelResponse = await response.Content.ReadFromJsonAsync(token); return modelResponse.Data; } -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Provider/Fireworks/ChatRequest.cs b/app/MindWork AI Studio/Provider/Fireworks/ChatRequest.cs deleted file mode 100644 index 54963feb..00000000 --- a/app/MindWork AI Studio/Provider/Fireworks/ChatRequest.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Text.Json.Serialization; - -namespace AIStudio.Provider.Fireworks; - -/// -/// The Fireworks chat request model. -/// -/// Which model to use for chat completion. -/// The chat messages. -/// Whether to stream the chat completion. -public readonly record struct ChatRequest( - string Model, - IList Messages, - bool Stream -) -{ - // Attention: The "required" modifier is not supported for [JsonExtensionData]. - [JsonExtensionData] - public IDictionary AdditionalApiParameters { get; init; } = new Dictionary(); -} \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/Fireworks/ProviderFireworks.cs b/app/MindWork AI Studio/Provider/Fireworks/ProviderFireworks.cs index 2254b7ad..0091e7a1 100644 --- a/app/MindWork AI Studio/Provider/Fireworks/ProviderFireworks.cs +++ b/app/MindWork AI Studio/Provider/Fireworks/ProviderFireworks.cs @@ -1,7 +1,4 @@ -using System.Net.Http.Headers; using System.Runtime.CompilerServices; -using System.Text; -using System.Text.Json; using AIStudio.Chat; using AIStudio.Provider.OpenAI; @@ -24,53 +21,31 @@ public class ProviderFireworks() : BaseProvider(LLMProviders.FIREWORKS, "https:/ /// public override async IAsyncEnumerable StreamChatCompletion(Model chatModel, ChatThread chatThread, SettingsManager settingsManager, [EnumeratorCancellation] CancellationToken token = default) { - // Get the API key: - var requestedSecret = await RUST_SERVICE.GetAPIKey(this, SecretStoreType.LLM_PROVIDER); - if(!requestedSecret.Success) - yield break; + await foreach (var content in this.StreamOpenAICompatibleChatCompletion( + "Fireworks", + chatModel, + chatThread, + settingsManager, + async (systemPrompt, apiParameters) => + { + // Build the list of messages: + var messages = await chatThread.Blocks.BuildMessagesUsingNestedImageUrlAsync(this.Provider, chatModel); - // Prepare the system prompt: - var systemPrompt = new TextMessage - { - Role = "system", - Content = chatThread.PrepareSystemPrompt(settingsManager), - }; - - // Parse the API parameters: - var apiParameters = this.ParseAdditionalApiParameters(); - - // Build the list of messages: - var messages = await chatThread.Blocks.BuildMessagesUsingNestedImageUrlAsync(this.Provider, chatModel); - - // Prepare the Fireworks HTTP chat request: - var fireworksChatRequest = JsonSerializer.Serialize(new ChatRequest - { - Model = chatModel.Id, - - // Build the messages: - // - First of all the system prompt - // - Then none-empty user and AI messages - Messages = [systemPrompt, ..messages], - - // Right now, we only support streaming completions: - Stream = true, - AdditionalApiParameters = apiParameters - }, JSON_SERIALIZER_OPTIONS); + return new ChatCompletionAPIRequest + { + Model = chatModel.Id, - async Task RequestBuilder() - { - // Build the HTTP post request: - var request = new HttpRequestMessage(HttpMethod.Post, "chat/completions"); + // Build the messages: + // - First of all the system prompt + // - Then none-empty user and AI messages + Messages = [systemPrompt, ..messages], - // Set the authorization header: - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await requestedSecret.Secret.Decrypt(ENCRYPTION)); - - // Set the content: - request.Content = new StringContent(fireworksChatRequest, Encoding.UTF8, "application/json"); - return request; - } - - await foreach (var content in this.StreamChatCompletionInternal("Fireworks", RequestBuilder, token)) + // Right now, we only support streaming completions: + Stream = true, + AdditionalApiParameters = apiParameters + }; + }, + token: token)) yield return content; } @@ -126,4 +101,4 @@ public class ProviderFireworks() : BaseProvider(LLMProviders.FIREWORKS, "https:/ } #endregion -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Provider/GWDG/ProviderGWDG.cs b/app/MindWork AI Studio/Provider/GWDG/ProviderGWDG.cs index 41e19fa9..edae7ae9 100644 --- a/app/MindWork AI Studio/Provider/GWDG/ProviderGWDG.cs +++ b/app/MindWork AI Studio/Provider/GWDG/ProviderGWDG.cs @@ -1,7 +1,5 @@ using System.Net.Http.Headers; using System.Runtime.CompilerServices; -using System.Text; -using System.Text.Json; using AIStudio.Chat; using AIStudio.Provider.OpenAI; @@ -24,52 +22,30 @@ public sealed class ProviderGWDG() : BaseProvider(LLMProviders.GWDG, "https://ch /// public override async IAsyncEnumerable StreamChatCompletion(Model chatModel, ChatThread chatThread, SettingsManager settingsManager, [EnumeratorCancellation] CancellationToken token = default) { - // Get the API key: - var requestedSecret = await RUST_SERVICE.GetAPIKey(this, SecretStoreType.LLM_PROVIDER); - if(!requestedSecret.Success) - yield break; - - // Prepare the system prompt: - var systemPrompt = new TextMessage - { - Role = "system", - Content = chatThread.PrepareSystemPrompt(settingsManager), - }; - - // Parse the API parameters: - var apiParameters = this.ParseAdditionalApiParameters(); - - // Build the list of messages: - var messages = await chatThread.Blocks.BuildMessagesUsingNestedImageUrlAsync(this.Provider, chatModel); - - // Prepare the GWDG HTTP chat request: - var gwdgChatRequest = JsonSerializer.Serialize(new ChatCompletionAPIRequest - { - Model = chatModel.Id, - - // Build the messages: - // - First of all the system prompt - // - Then none-empty user and AI messages - Messages = [systemPrompt, ..messages], - - Stream = true, - AdditionalApiParameters = apiParameters - }, JSON_SERIALIZER_OPTIONS); + await foreach (var content in this.StreamOpenAICompatibleChatCompletion( + "GWDG", + chatModel, + chatThread, + settingsManager, + async (systemPrompt, apiParameters) => + { + // Build the list of messages: + var messages = await chatThread.Blocks.BuildMessagesUsingNestedImageUrlAsync(this.Provider, chatModel); - async Task RequestBuilder() - { - // Build the HTTP post request: - var request = new HttpRequestMessage(HttpMethod.Post, "chat/completions"); + return new ChatCompletionAPIRequest + { + Model = chatModel.Id, - // Set the authorization header: - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await requestedSecret.Secret.Decrypt(ENCRYPTION)); + // Build the messages: + // - First of all the system prompt + // - Then none-empty user and AI messages + Messages = [systemPrompt, ..messages], - // Set the content: - request.Content = new StringContent(gwdgChatRequest, Encoding.UTF8, "application/json"); - return request; - } - - await foreach (var content in this.StreamChatCompletionInternal("GWDG", RequestBuilder, token)) + Stream = true, + AdditionalApiParameters = apiParameters + }; + }, + token: token)) yield return content; } @@ -152,4 +128,4 @@ public sealed class ProviderGWDG() : BaseProvider(LLMProviders.GWDG, "https://ch var modelResponse = await response.Content.ReadFromJsonAsync(token); return modelResponse.Data; } -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Provider/Google/ChatRequest.cs b/app/MindWork AI Studio/Provider/Google/ChatRequest.cs deleted file mode 100644 index 1a898c3a..00000000 --- a/app/MindWork AI Studio/Provider/Google/ChatRequest.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Text.Json.Serialization; - -namespace AIStudio.Provider.Google; - -/// -/// The Google chat request model. -/// -/// Which model to use for chat completion. -/// The chat messages. -/// Whether to stream the chat completion. -public readonly record struct ChatRequest( - string Model, - IList Messages, - bool Stream -) -{ - // Attention: The "required" modifier is not supported for [JsonExtensionData]. - [JsonExtensionData] - public IDictionary AdditionalApiParameters { get; init; } = new Dictionary(); -} \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/Google/ProviderGoogle.cs b/app/MindWork AI Studio/Provider/Google/ProviderGoogle.cs index 8a86fcbe..0caf7b05 100644 --- a/app/MindWork AI Studio/Provider/Google/ProviderGoogle.cs +++ b/app/MindWork AI Studio/Provider/Google/ProviderGoogle.cs @@ -24,53 +24,31 @@ public class ProviderGoogle() : BaseProvider(LLMProviders.GOOGLE, "https://gener /// public override async IAsyncEnumerable StreamChatCompletion(Model chatModel, ChatThread chatThread, SettingsManager settingsManager, [EnumeratorCancellation] CancellationToken token = default) { - // Get the API key: - var requestedSecret = await RUST_SERVICE.GetAPIKey(this, SecretStoreType.LLM_PROVIDER); - if(!requestedSecret.Success) - yield break; + await foreach (var content in this.StreamOpenAICompatibleChatCompletion( + "Google", + chatModel, + chatThread, + settingsManager, + async (systemPrompt, apiParameters) => + { + // Build the list of messages: + var messages = await chatThread.Blocks.BuildMessagesUsingNestedImageUrlAsync(this.Provider, chatModel); - // Prepare the system prompt: - var systemPrompt = new TextMessage - { - Role = "system", - Content = chatThread.PrepareSystemPrompt(settingsManager), - }; - - // Parse the API parameters: - var apiParameters = this.ParseAdditionalApiParameters(); - - // Build the list of messages: - var messages = await chatThread.Blocks.BuildMessagesUsingNestedImageUrlAsync(this.Provider, chatModel); - - // Prepare the Google HTTP chat request: - var geminiChatRequest = JsonSerializer.Serialize(new ChatRequest - { - Model = chatModel.Id, - - // Build the messages: - // - First of all the system prompt - // - Then none-empty user and AI messages - Messages = [systemPrompt, ..messages], - - // Right now, we only support streaming completions: - Stream = true, - AdditionalApiParameters = apiParameters - }, JSON_SERIALIZER_OPTIONS); + return new ChatCompletionAPIRequest + { + Model = chatModel.Id, - async Task RequestBuilder() - { - // Build the HTTP post request: - var request = new HttpRequestMessage(HttpMethod.Post, "chat/completions"); + // Build the messages: + // - First of all the system prompt + // - Then none-empty user and AI messages + Messages = [systemPrompt, ..messages], - // Set the authorization header: - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await requestedSecret.Secret.Decrypt(ENCRYPTION)); - - // Set the content: - request.Content = new StringContent(geminiChatRequest, Encoding.UTF8, "application/json"); - return request; - } - - await foreach (var content in this.StreamChatCompletionInternal("Google", RequestBuilder, token)) + // Right now, we only support streaming completions: + Stream = true, + AdditionalApiParameters = apiParameters + }; + }, + token: token)) yield return content; } @@ -256,4 +234,4 @@ public class ProviderGoogle() : BaseProvider(LLMProviders.GOOGLE, "https://gener ? modelId["models/".Length..] : modelId; } -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Provider/Groq/ChatRequest.cs b/app/MindWork AI Studio/Provider/Groq/ChatRequest.cs deleted file mode 100644 index 2e7668f1..00000000 --- a/app/MindWork AI Studio/Provider/Groq/ChatRequest.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Text.Json.Serialization; - -namespace AIStudio.Provider.Groq; - -/// -/// The Groq chat request model. -/// -/// Which model to use for chat completion. -/// The chat messages. -/// Whether to stream the chat completion. -/// The seed for the chat completion. -public readonly record struct ChatRequest( - string Model, - IList Messages, - bool Stream, - int Seed -) -{ - // Attention: The "required" modifier is not supported for [JsonExtensionData]. - [JsonExtensionData] - public IDictionary AdditionalApiParameters { get; init; } = new Dictionary(); -} \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/Groq/ProviderGroq.cs b/app/MindWork AI Studio/Provider/Groq/ProviderGroq.cs index 8f938667..d36951f0 100644 --- a/app/MindWork AI Studio/Provider/Groq/ProviderGroq.cs +++ b/app/MindWork AI Studio/Provider/Groq/ProviderGroq.cs @@ -1,7 +1,5 @@ using System.Net.Http.Headers; using System.Runtime.CompilerServices; -using System.Text; -using System.Text.Json; using AIStudio.Chat; using AIStudio.Provider.OpenAI; @@ -24,53 +22,34 @@ public class ProviderGroq() : BaseProvider(LLMProviders.GROQ, "https://api.groq. /// public override async IAsyncEnumerable StreamChatCompletion(Model chatModel, ChatThread chatThread, SettingsManager settingsManager, [EnumeratorCancellation] CancellationToken token = default) { - // Get the API key: - var requestedSecret = await RUST_SERVICE.GetAPIKey(this, SecretStoreType.LLM_PROVIDER); - if(!requestedSecret.Success) - yield break; + await foreach (var content in this.StreamOpenAICompatibleChatCompletion( + "Groq", + chatModel, + chatThread, + settingsManager, + async (systemPrompt, apiParameters) => + { + if (TryPopIntParameter(apiParameters, "seed", out var parsedSeed)) + apiParameters["seed"] = parsedSeed; - // Prepare the system prompt: - var systemPrompt = new TextMessage - { - Role = "system", - Content = chatThread.PrepareSystemPrompt(settingsManager), - }; - - // Parse the API parameters: - var apiParameters = this.ParseAdditionalApiParameters(); - - // Build the list of messages: - var messages = await chatThread.Blocks.BuildMessagesUsingNestedImageUrlAsync(this.Provider, chatModel); - - // Prepare the OpenAI HTTP chat request: - var groqChatRequest = JsonSerializer.Serialize(new ChatRequest - { - Model = chatModel.Id, - - // Build the messages: - // - First of all the system prompt - // - Then none-empty user and AI messages - Messages = [systemPrompt, ..messages], - - // Right now, we only support streaming completions: - Stream = true, - AdditionalApiParameters = apiParameters - }, JSON_SERIALIZER_OPTIONS); + // Build the list of messages: + var messages = await chatThread.Blocks.BuildMessagesUsingNestedImageUrlAsync(this.Provider, chatModel); - async Task RequestBuilder() - { - // Build the HTTP post request: - var request = new HttpRequestMessage(HttpMethod.Post, "chat/completions"); + return new ChatCompletionAPIRequest + { + Model = chatModel.Id, - // Set the authorization header: - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await requestedSecret.Secret.Decrypt(ENCRYPTION)); + // Build the messages: + // - First of all the system prompt + // - Then none-empty user and AI messages + Messages = [systemPrompt, ..messages], - // Set the content: - request.Content = new StringContent(groqChatRequest, Encoding.UTF8, "application/json"); - return request; - } - - await foreach (var content in this.StreamChatCompletionInternal("Groq", RequestBuilder, token)) + // Right now, we only support streaming completions: + Stream = true, + AdditionalApiParameters = apiParameters + }; + }, + token: token)) yield return content; } @@ -148,4 +127,4 @@ public class ProviderGroq() : BaseProvider(LLMProviders.GROQ, "https://api.groq. !n.Id.StartsWith("distil-", StringComparison.OrdinalIgnoreCase) && !n.Id.Contains("-tts", StringComparison.OrdinalIgnoreCase)); } -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Provider/Helmholtz/ProviderHelmholtz.cs b/app/MindWork AI Studio/Provider/Helmholtz/ProviderHelmholtz.cs index 070597a3..bfa7a758 100644 --- a/app/MindWork AI Studio/Provider/Helmholtz/ProviderHelmholtz.cs +++ b/app/MindWork AI Studio/Provider/Helmholtz/ProviderHelmholtz.cs @@ -1,7 +1,5 @@ using System.Net.Http.Headers; using System.Runtime.CompilerServices; -using System.Text; -using System.Text.Json; using AIStudio.Chat; using AIStudio.Provider.OpenAI; @@ -24,52 +22,30 @@ public sealed class ProviderHelmholtz() : BaseProvider(LLMProviders.HELMHOLTZ, " /// public override async IAsyncEnumerable StreamChatCompletion(Model chatModel, ChatThread chatThread, SettingsManager settingsManager, [EnumeratorCancellation] CancellationToken token = default) { - // Get the API key: - var requestedSecret = await RUST_SERVICE.GetAPIKey(this, SecretStoreType.LLM_PROVIDER); - if(!requestedSecret.Success) - yield break; - - // Prepare the system prompt: - var systemPrompt = new TextMessage - { - Role = "system", - Content = chatThread.PrepareSystemPrompt(settingsManager), - }; - - // Parse the API parameters: - var apiParameters = this.ParseAdditionalApiParameters(); - - // Build the list of messages: - var messages = await chatThread.Blocks.BuildMessagesUsingNestedImageUrlAsync(this.Provider, chatModel); - - // Prepare the Helmholtz HTTP chat request: - var helmholtzChatRequest = JsonSerializer.Serialize(new ChatCompletionAPIRequest - { - Model = chatModel.Id, - - // Build the messages: - // - First of all the system prompt - // - Then none-empty user and AI messages - Messages = [systemPrompt, ..messages], - - Stream = true, - AdditionalApiParameters = apiParameters - }, JSON_SERIALIZER_OPTIONS); + await foreach (var content in this.StreamOpenAICompatibleChatCompletion( + "Helmholtz", + chatModel, + chatThread, + settingsManager, + async (systemPrompt, apiParameters) => + { + // Build the list of messages: + var messages = await chatThread.Blocks.BuildMessagesUsingNestedImageUrlAsync(this.Provider, chatModel); - async Task RequestBuilder() - { - // Build the HTTP post request: - var request = new HttpRequestMessage(HttpMethod.Post, "chat/completions"); + return new ChatCompletionAPIRequest + { + Model = chatModel.Id, - // Set the authorization header: - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await requestedSecret.Secret.Decrypt(ENCRYPTION)); + // Build the messages: + // - First of all the system prompt + // - Then none-empty user and AI messages + Messages = [systemPrompt, ..messages], - // Set the content: - request.Content = new StringContent(helmholtzChatRequest, Encoding.UTF8, "application/json"); - return request; - } - - await foreach (var content in this.StreamChatCompletionInternal("Helmholtz", RequestBuilder, token)) + Stream = true, + AdditionalApiParameters = apiParameters + }; + }, + token: token)) yield return content; } @@ -151,4 +127,4 @@ public sealed class ProviderHelmholtz() : BaseProvider(LLMProviders.HELMHOLTZ, " var modelResponse = await response.Content.ReadFromJsonAsync(token); return modelResponse.Data; } -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Provider/HuggingFace/ProviderHuggingFace.cs b/app/MindWork AI Studio/Provider/HuggingFace/ProviderHuggingFace.cs index f2e8c380..c22b5c50 100644 --- a/app/MindWork AI Studio/Provider/HuggingFace/ProviderHuggingFace.cs +++ b/app/MindWork AI Studio/Provider/HuggingFace/ProviderHuggingFace.cs @@ -1,7 +1,4 @@ -using System.Net.Http.Headers; -using System.Runtime.CompilerServices; -using System.Text; -using System.Text.Json; +using System.Runtime.CompilerServices; using AIStudio.Chat; using AIStudio.Provider.OpenAI; @@ -29,52 +26,30 @@ public sealed class ProviderHuggingFace : BaseProvider /// public override async IAsyncEnumerable StreamChatCompletion(Model chatModel, ChatThread chatThread, SettingsManager settingsManager, [EnumeratorCancellation] CancellationToken token = default) { - // Get the API key: - var requestedSecret = await RUST_SERVICE.GetAPIKey(this, SecretStoreType.LLM_PROVIDER); - if(!requestedSecret.Success) - yield break; + await foreach (var content in this.StreamOpenAICompatibleChatCompletion( + "HuggingFace", + chatModel, + chatThread, + settingsManager, + async (systemPrompt, apiParameters) => + { + // Build the list of messages: + var messages = await chatThread.Blocks.BuildMessagesUsingNestedImageUrlAsync(this.Provider, chatModel); - // Prepare the system prompt: - var systemPrompt = new TextMessage - { - Role = "system", - Content = chatThread.PrepareSystemPrompt(settingsManager), - }; - - // Parse the API parameters: - var apiParameters = this.ParseAdditionalApiParameters(); - - // Build the list of messages: - var message = await chatThread.Blocks.BuildMessagesUsingNestedImageUrlAsync(this.Provider, chatModel); - - // Prepare the HuggingFace HTTP chat request: - var huggingfaceChatRequest = JsonSerializer.Serialize(new ChatCompletionAPIRequest - { - Model = chatModel.Id, - - // Build the messages: - // - First of all the system prompt - // - Then none-empty user and AI messages - Messages = [systemPrompt, ..message], - - Stream = true, - AdditionalApiParameters = apiParameters - }, JSON_SERIALIZER_OPTIONS); + return new ChatCompletionAPIRequest + { + Model = chatModel.Id, - async Task RequestBuilder() - { - // Build the HTTP post request: - var request = new HttpRequestMessage(HttpMethod.Post, "chat/completions"); + // Build the messages: + // - First of all the system prompt + // - Then none-empty user and AI messages + Messages = [systemPrompt, ..messages], - // Set the authorization header: - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await requestedSecret.Secret.Decrypt(ENCRYPTION)); - - // Set the content: - request.Content = new StringContent(huggingfaceChatRequest, Encoding.UTF8, "application/json"); - return request; - } - - await foreach (var content in this.StreamChatCompletionInternal("HuggingFace", RequestBuilder, token)) + Stream = true, + AdditionalApiParameters = apiParameters + }; + }, + token: token)) yield return content; } @@ -123,4 +98,4 @@ public sealed class ProviderHuggingFace : BaseProvider } #endregion -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Provider/Mistral/ChatRequest.cs b/app/MindWork AI Studio/Provider/Mistral/ChatRequest.cs deleted file mode 100644 index 1d42081f..00000000 --- a/app/MindWork AI Studio/Provider/Mistral/ChatRequest.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Text.Json.Serialization; - -namespace AIStudio.Provider.Mistral; - -/// -/// The OpenAI chat request model. -/// -/// Which model to use for chat completion. -/// The chat messages. -/// Whether to stream the chat completion. -/// The seed for the chat completion. -/// Whether to inject a safety prompt before all conversations. -public readonly record struct ChatRequest( - string Model, - IList Messages, - bool Stream, - [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - int? RandomSeed, - bool SafePrompt = false -) -{ - // Attention: The "required" modifier is not supported for [JsonExtensionData]. - [JsonExtensionData] - public IDictionary AdditionalApiParameters { get; init; } = new Dictionary(); -} diff --git a/app/MindWork AI Studio/Provider/Mistral/ProviderMistral.cs b/app/MindWork AI Studio/Provider/Mistral/ProviderMistral.cs index 485729fb..e4445300 100644 --- a/app/MindWork AI Studio/Provider/Mistral/ProviderMistral.cs +++ b/app/MindWork AI Studio/Provider/Mistral/ProviderMistral.cs @@ -1,7 +1,5 @@ using System.Net.Http.Headers; using System.Runtime.CompilerServices; -using System.Text; -using System.Text.Json; using AIStudio.Chat; using AIStudio.Provider.OpenAI; @@ -22,58 +20,37 @@ public sealed class ProviderMistral() : BaseProvider(LLMProviders.MISTRAL, "http /// public override async IAsyncEnumerable StreamChatCompletion(Provider.Model chatModel, ChatThread chatThread, SettingsManager settingsManager, [EnumeratorCancellation] CancellationToken token = default) { - // Get the API key: - var requestedSecret = await RUST_SERVICE.GetAPIKey(this, SecretStoreType.LLM_PROVIDER); - if(!requestedSecret.Success) - yield break; + await foreach (var content in this.StreamOpenAICompatibleChatCompletion( + "Mistral", + chatModel, + chatThread, + settingsManager, + async (systemPrompt, apiParameters) => + { + if (TryPopBoolParameter(apiParameters, "safe_prompt", out var parsedSafePrompt)) + apiParameters["safe_prompt"] = parsedSafePrompt; - // Prepare the system prompt: - var systemPrompt = new TextMessage - { - Role = "system", - Content = chatThread.PrepareSystemPrompt(settingsManager), - }; - - // Parse the API parameters: - var apiParameters = this.ParseAdditionalApiParameters(); - var safePrompt = TryPopBoolParameter(apiParameters, "safe_prompt", out var parsedSafePrompt) && parsedSafePrompt; - var randomSeed = TryPopIntParameter(apiParameters, "random_seed", out var parsedRandomSeed) ? parsedRandomSeed : (int?)null; + if (TryPopIntParameter(apiParameters, "random_seed", out var parsedRandomSeed)) + apiParameters["random_seed"] = parsedRandomSeed; - // Build the list of messages: - var messages = await chatThread.Blocks.BuildMessagesUsingDirectImageUrlAsync(this.Provider, chatModel); - - // Prepare the Mistral HTTP chat request: - var mistralChatRequest = JsonSerializer.Serialize(new ChatRequest - { - Model = chatModel.Id, - - // Build the messages: - // - First of all the system prompt - // - Then none-empty user and AI messages - Messages = [systemPrompt, ..messages], - - // Right now, we only support streaming completions: - Stream = true, - RandomSeed = randomSeed, - SafePrompt = safePrompt, - AdditionalApiParameters = apiParameters - }, JSON_SERIALIZER_OPTIONS); + // Build the list of messages: + var messages = await chatThread.Blocks.BuildMessagesUsingDirectImageUrlAsync(this.Provider, chatModel); - - async Task RequestBuilder() - { - // Build the HTTP post request: - var request = new HttpRequestMessage(HttpMethod.Post, "chat/completions"); + return new ChatCompletionAPIRequest + { + Model = chatModel.Id, - // Set the authorization header: - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await requestedSecret.Secret.Decrypt(ENCRYPTION)); + // Build the messages: + // - First of all the system prompt + // - Then none-empty user and AI messages + Messages = [systemPrompt, ..messages], - // Set the content: - request.Content = new StringContent(mistralChatRequest, Encoding.UTF8, "application/json"); - return request; - } - - await foreach (var content in this.StreamChatCompletionInternal("Mistral", RequestBuilder, token)) + // Right now, we only support streaming completions: + Stream = true, + AdditionalApiParameters = apiParameters + }; + }, + token: token)) yield return content; } diff --git a/app/MindWork AI Studio/Provider/OpenAI/ProviderOpenAI.cs b/app/MindWork AI Studio/Provider/OpenAI/ProviderOpenAI.cs index e5b6ebfd..d0c211bb 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ProviderOpenAI.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ProviderOpenAI.cs @@ -79,9 +79,9 @@ public sealed class ProviderOpenAI() : BaseProvider(LLMProviders.OPEN_AI, "https // // Prepare the tools we want to use: // - IList tools = modelCapabilities.Contains(Capability.WEB_SEARCH) switch + IList providerTools = modelCapabilities.Contains(Capability.WEB_SEARCH) switch { - true => [ Tools.WEB_SEARCH ], + true => [ ProviderTools.WEB_SEARCH ], _ => [] }; @@ -178,7 +178,7 @@ public sealed class ProviderOpenAI() : BaseProvider(LLMProviders.OPEN_AI, "https Store = false, // Tools we want to use: - Tools = tools, + ProviderTools = providerTools, // Additional API parameters: AdditionalApiParameters = apiParameters @@ -290,4 +290,4 @@ public sealed class ProviderOpenAI() : BaseProvider(LLMProviders.OPEN_AI, "https var modelResponse = await response.Content.ReadFromJsonAsync(token); return modelResponse.Data.Where(model => prefixes.Any(prefix => model.Id.StartsWith(prefix, StringComparison.InvariantCulture))); } -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Provider/OpenAI/Tool.cs b/app/MindWork AI Studio/Provider/OpenAI/ProviderTool.cs similarity index 79% rename from app/MindWork AI Studio/Provider/OpenAI/Tool.cs rename to app/MindWork AI Studio/Provider/OpenAI/ProviderTool.cs index 782e6b60..61170af3 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/Tool.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ProviderTool.cs @@ -1,7 +1,7 @@ namespace AIStudio.Provider.OpenAI; /// -/// Represents a tool used by the AI model. +/// Represents a tool executed on the provider side. /// /// /// Right now, only our OpenAI provider is using tools. Thus, this class is located in the @@ -9,4 +9,4 @@ namespace AIStudio.Provider.OpenAI; /// be moved into the provider namespace. /// /// The type of the tool. -public record Tool(string Type); \ No newline at end of file +public record ProviderTool(string Type); diff --git a/app/MindWork AI Studio/Provider/OpenAI/Tools.cs b/app/MindWork AI Studio/Provider/OpenAI/ProviderTools.cs similarity index 67% rename from app/MindWork AI Studio/Provider/OpenAI/Tools.cs rename to app/MindWork AI Studio/Provider/OpenAI/ProviderTools.cs index 50d2b836..359c781b 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/Tools.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ProviderTools.cs @@ -1,14 +1,14 @@ namespace AIStudio.Provider.OpenAI; /// -/// Known tools for LLM providers. +/// Known provider-side tools for LLM providers. /// /// /// Right now, only our OpenAI provider is using tools. Thus, this class is located in the /// OpenAI namespace. In the future, when other providers also support tools, this class can /// be moved into the provider namespace. /// -public static class Tools +public static class ProviderTools { - public static readonly Tool WEB_SEARCH = new("web_search"); -} \ No newline at end of file + public static readonly ProviderTool WEB_SEARCH = new("web_search"); +} diff --git a/app/MindWork AI Studio/Provider/OpenAI/ResponsesAPIRequest.cs b/app/MindWork AI Studio/Provider/OpenAI/ResponsesAPIRequest.cs index deb315d6..739ad7ad 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ResponsesAPIRequest.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ResponsesAPIRequest.cs @@ -9,13 +9,13 @@ namespace AIStudio.Provider.OpenAI; /// The chat messages. /// Whether to stream the response. /// Whether to store the response on the server (usually OpenAI's infrastructure). -/// The tools to use for the request. +/// The provider-side tools to use for the request. public record ResponsesAPIRequest( string Model, IList Input, bool Stream, bool Store, - IList Tools) + [property: JsonPropertyName("tools")] IList ProviderTools) { public ResponsesAPIRequest() : this(string.Empty, [], true, false, []) { @@ -24,4 +24,4 @@ public record ResponsesAPIRequest( // Attention: The "required" modifier is not supported for [JsonExtensionData]. [JsonExtensionData] public IDictionary AdditionalApiParameters { get; init; } = new Dictionary(); -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Provider/OpenRouter/ProviderOpenRouter.cs b/app/MindWork AI Studio/Provider/OpenRouter/ProviderOpenRouter.cs index 4995cca9..9f2c1b13 100644 --- a/app/MindWork AI Studio/Provider/OpenRouter/ProviderOpenRouter.cs +++ b/app/MindWork AI Studio/Provider/OpenRouter/ProviderOpenRouter.cs @@ -1,7 +1,5 @@ using System.Net.Http.Headers; using System.Runtime.CompilerServices; -using System.Text; -using System.Text.Json; using AIStudio.Chat; using AIStudio.Provider.OpenAI; @@ -27,57 +25,37 @@ public sealed class ProviderOpenRouter() : BaseProvider(LLMProviders.OPEN_ROUTER /// public override async IAsyncEnumerable StreamChatCompletion(Model chatModel, ChatThread chatThread, SettingsManager settingsManager, [EnumeratorCancellation] CancellationToken token = default) { - // Get the API key: - var requestedSecret = await RUST_SERVICE.GetAPIKey(this, SecretStoreType.LLM_PROVIDER); - if(!requestedSecret.Success) - yield break; + await foreach (var content in this.StreamOpenAICompatibleChatCompletion( + "OpenRouter", + chatModel, + chatThread, + settingsManager, + async (systemPrompt, apiParameters) => + { + // Build the list of messages: + var messages = await chatThread.Blocks.BuildMessagesUsingNestedImageUrlAsync(this.Provider, chatModel); - // Prepare the system prompt: - var systemPrompt = new TextMessage - { - Role = "system", - Content = chatThread.PrepareSystemPrompt(settingsManager), - }; + return new ChatCompletionAPIRequest + { + Model = chatModel.Id, - // Parse the API parameters: - var apiParameters = this.ParseAdditionalApiParameters(); - - // Build the list of messages: - var messages = await chatThread.Blocks.BuildMessagesUsingNestedImageUrlAsync(this.Provider, chatModel); + // Build the messages: + // - First of all the system prompt + // - Then none-empty user and AI messages + Messages = [systemPrompt, ..messages], - // Prepare the OpenRouter HTTP chat request: - var openRouterChatRequest = JsonSerializer.Serialize(new ChatCompletionAPIRequest - { - Model = chatModel.Id, - - // Build the messages: - // - First of all the system prompt - // - Then none-empty user and AI messages - Messages = [systemPrompt, ..messages], - - // Right now, we only support streaming completions: - Stream = true, - AdditionalApiParameters = apiParameters - }, JSON_SERIALIZER_OPTIONS); - - async Task RequestBuilder() - { - // Build the HTTP post request: - var request = new HttpRequestMessage(HttpMethod.Post, "chat/completions"); - - // Set the authorization header: - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await requestedSecret.Secret.Decrypt(ENCRYPTION)); - - // Set custom headers for project identification: - request.Headers.Add("HTTP-Referer", PROJECT_WEBSITE); - request.Headers.Add("X-Title", PROJECT_NAME); - - // Set the content: - request.Content = new StringContent(openRouterChatRequest, Encoding.UTF8, "application/json"); - return request; - } - - await foreach (var content in this.StreamChatCompletionInternal("OpenRouter", RequestBuilder, token)) + // Right now, we only support streaming completions: + Stream = true, + AdditionalApiParameters = apiParameters + }; + }, + headersAction: headers => + { + // Set custom headers for project identification: + headers.Add("HTTP-Referer", PROJECT_WEBSITE); + headers.Add("X-Title", PROJECT_NAME); + }, + token: token)) yield return content; } diff --git a/app/MindWork AI Studio/Provider/Perplexity/ProviderPerplexity.cs b/app/MindWork AI Studio/Provider/Perplexity/ProviderPerplexity.cs index 4c73dc2d..745dd974 100644 --- a/app/MindWork AI Studio/Provider/Perplexity/ProviderPerplexity.cs +++ b/app/MindWork AI Studio/Provider/Perplexity/ProviderPerplexity.cs @@ -1,7 +1,4 @@ -using System.Net.Http.Headers; using System.Runtime.CompilerServices; -using System.Text; -using System.Text.Json; using AIStudio.Chat; using AIStudio.Provider.OpenAI; @@ -33,51 +30,29 @@ public sealed class ProviderPerplexity() : BaseProvider(LLMProviders.PERPLEXITY, /// public override async IAsyncEnumerable StreamChatCompletion(Model chatModel, ChatThread chatThread, SettingsManager settingsManager, [EnumeratorCancellation] CancellationToken token = default) { - // Get the API key: - var requestedSecret = await RUST_SERVICE.GetAPIKey(this, SecretStoreType.LLM_PROVIDER); - if(!requestedSecret.Success) - yield break; - - // Prepare the system prompt: - var systemPrompt = new TextMessage - { - Role = "system", - Content = chatThread.PrepareSystemPrompt(settingsManager), - }; - - // Parse the API parameters: - var apiParameters = this.ParseAdditionalApiParameters(); - - // Build the list of messages: - var messages = await chatThread.Blocks.BuildMessagesUsingNestedImageUrlAsync(this.Provider, chatModel); - - // Prepare the Perplexity HTTP chat request: - var perplexityChatRequest = JsonSerializer.Serialize(new ChatCompletionAPIRequest - { - Model = chatModel.Id, - - // Build the messages: - // - First of all the system prompt - // - Then none-empty user and AI messages - Messages = [systemPrompt, ..messages], - Stream = true, - AdditionalApiParameters = apiParameters - }, JSON_SERIALIZER_OPTIONS); + await foreach (var content in this.StreamOpenAICompatibleChatCompletion( + "Perplexity", + chatModel, + chatThread, + settingsManager, + async (systemPrompt, apiParameters) => + { + // Build the list of messages: + var messages = await chatThread.Blocks.BuildMessagesUsingNestedImageUrlAsync(this.Provider, chatModel); - async Task RequestBuilder() - { - // Build the HTTP post request: - var request = new HttpRequestMessage(HttpMethod.Post, "chat/completions"); + return new ChatCompletionAPIRequest + { + Model = chatModel.Id, - // Set the authorization header: - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await requestedSecret.Secret.Decrypt(ENCRYPTION)); - - // Set the content: - request.Content = new StringContent(perplexityChatRequest, Encoding.UTF8, "application/json"); - return request; - } - - await foreach (var content in this.StreamChatCompletionInternal("Perplexity", RequestBuilder, token)) + // Build the messages: + // - First of all the system prompt + // - Then none-empty user and AI messages + Messages = [systemPrompt, ..messages], + Stream = true, + AdditionalApiParameters = apiParameters + }; + }, + token: token)) yield return content; } @@ -128,4 +103,4 @@ public sealed class ProviderPerplexity() : BaseProvider(LLMProviders.PERPLEXITY, #endregion private Task> LoadModels() => Task.FromResult>(KNOWN_MODELS); -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Provider/SelfHosted/ChatRequest.cs b/app/MindWork AI Studio/Provider/SelfHosted/ChatRequest.cs deleted file mode 100644 index e1da56bd..00000000 --- a/app/MindWork AI Studio/Provider/SelfHosted/ChatRequest.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Text.Json.Serialization; - -namespace AIStudio.Provider.SelfHosted; - -/// -/// The chat request model. -/// -/// Which model to use for chat completion. -/// The chat messages. -/// Whether to stream the chat completion. -public readonly record struct ChatRequest( - string Model, - IList Messages, - bool Stream -) -{ - // Attention: The "required" modifier is not supported for [JsonExtensionData]. - [JsonExtensionData] - public IDictionary AdditionalApiParameters { get; init; } = new Dictionary(); -} \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs b/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs index 8204fa6c..01e86cc3 100644 --- a/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs +++ b/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs @@ -1,7 +1,5 @@ using System.Net.Http.Headers; using System.Runtime.CompilerServices; -using System.Text; -using System.Text.Json; using AIStudio.Chat; using AIStudio.Provider.OpenAI; @@ -25,58 +23,39 @@ public sealed class ProviderSelfHosted(Host host, string hostname) : BaseProvide /// public override async IAsyncEnumerable StreamChatCompletion(Provider.Model chatModel, ChatThread chatThread, SettingsManager settingsManager, [EnumeratorCancellation] CancellationToken token = default) { - // Get the API key: - var requestedSecret = await RUST_SERVICE.GetAPIKey(this, SecretStoreType.LLM_PROVIDER, isTrying: true); - - // Prepare the system prompt: - var systemPrompt = new TextMessage - { - Role = "system", - Content = chatThread.PrepareSystemPrompt(settingsManager), - }; - - // Parse the API parameters: - var apiParameters = this.ParseAdditionalApiParameters(); + await foreach (var content in this.StreamOpenAICompatibleChatCompletion( + "self-hosted provider", + chatModel, + chatThread, + settingsManager, + async (systemPrompt, apiParameters) => + { + // 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:..." } + // - LM Studio, vLLM, and llama.cpp use the nested image URL format: { "type": "image_url", "image_url": { "url": "data:..." } } + var messages = host switch + { + Host.OLLAMA => await chatThread.Blocks.BuildMessagesUsingDirectImageUrlAsync(this.Provider, chatModel), + _ => await chatThread.Blocks.BuildMessagesUsingNestedImageUrlAsync(this.Provider, chatModel), + }; - // 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:..." } - // - LM Studio, vLLM, and llama.cpp use the nested image URL format: { "type": "image_url", "image_url": { "url": "data:..." } } - var messages = host switch - { - Host.OLLAMA => await chatThread.Blocks.BuildMessagesUsingDirectImageUrlAsync(this.Provider, chatModel), - _ => await chatThread.Blocks.BuildMessagesUsingNestedImageUrlAsync(this.Provider, chatModel), - }; - - // Prepare the OpenAI HTTP chat request: - var providerChatRequest = JsonSerializer.Serialize(new ChatRequest - { - Model = chatModel.Id, - - // Build the messages: - // - First of all the system prompt - // - Then none-empty user and AI messages - Messages = [systemPrompt, ..messages], - - // Right now, we only support streaming completions: - Stream = true, - AdditionalApiParameters = apiParameters - }, JSON_SERIALIZER_OPTIONS); + return new ChatCompletionAPIRequest + { + Model = chatModel.Id, - async Task RequestBuilder() - { - // Build the HTTP post request: - var request = new HttpRequestMessage(HttpMethod.Post, host.ChatURL()); + // Build the messages: + // - First of all the system prompt + // - Then none-empty user and AI messages + Messages = [systemPrompt, ..messages], - // Set the authorization header: - if (requestedSecret.Success) - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await requestedSecret.Secret.Decrypt(ENCRYPTION)); - - // Set the content: - request.Content = new StringContent(providerChatRequest, Encoding.UTF8, "application/json"); - return request; - } - - await foreach (var content in this.StreamChatCompletionInternal("self-hosted provider", RequestBuilder, token)) + // Right now, we only support streaming completions: + Stream = true, + AdditionalApiParameters = apiParameters + }; + }, + isTryingSecret: true, + requestPath: host.ChatURL(), + token: token)) yield return content; } @@ -211,4 +190,4 @@ public sealed class ProviderSelfHosted(Host host, string hostname) : BaseProvide filterPhrases.All( filter => model.Id.Contains(filter, StringComparison.InvariantCulture))) .Select(n => new Provider.Model(n.Id, null)); } -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Provider/X/ProviderX.cs b/app/MindWork AI Studio/Provider/X/ProviderX.cs index 21d6e2ca..8c1685ee 100644 --- a/app/MindWork AI Studio/Provider/X/ProviderX.cs +++ b/app/MindWork AI Studio/Provider/X/ProviderX.cs @@ -1,7 +1,5 @@ using System.Net.Http.Headers; using System.Runtime.CompilerServices; -using System.Text; -using System.Text.Json; using AIStudio.Chat; using AIStudio.Provider.OpenAI; @@ -24,53 +22,31 @@ public sealed class ProviderX() : BaseProvider(LLMProviders.X, "https://api.x.ai /// public override async IAsyncEnumerable StreamChatCompletion(Model chatModel, ChatThread chatThread, SettingsManager settingsManager, [EnumeratorCancellation] CancellationToken token = default) { - // Get the API key: - var requestedSecret = await RUST_SERVICE.GetAPIKey(this, SecretStoreType.LLM_PROVIDER); - if(!requestedSecret.Success) - yield break; - - // Prepare the system prompt: - var systemPrompt = new TextMessage - { - Role = "system", - Content = chatThread.PrepareSystemPrompt(settingsManager), - }; - - // Parse the API parameters: - var apiParameters = this.ParseAdditionalApiParameters(); - - // Build the list of messages: - var messages = await chatThread.Blocks.BuildMessagesUsingNestedImageUrlAsync(this.Provider, chatModel); - - // Prepare the xAI HTTP chat request: - var xChatRequest = JsonSerializer.Serialize(new ChatCompletionAPIRequest - { - Model = chatModel.Id, - - // Build the messages: - // - First of all the system prompt - // - Then none-empty user and AI messages - Messages = [systemPrompt, ..messages], - - // Right now, we only support streaming completions: - Stream = true, - AdditionalApiParameters = apiParameters - }, JSON_SERIALIZER_OPTIONS); + await foreach (var content in this.StreamOpenAICompatibleChatCompletion( + "xAI", + chatModel, + chatThread, + settingsManager, + async (systemPrompt, apiParameters) => + { + // Build the list of messages: + var messages = await chatThread.Blocks.BuildMessagesUsingNestedImageUrlAsync(this.Provider, chatModel); - async Task RequestBuilder() - { - // Build the HTTP post request: - var request = new HttpRequestMessage(HttpMethod.Post, "chat/completions"); + return new ChatCompletionAPIRequest + { + Model = chatModel.Id, - // Set the authorization header: - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await requestedSecret.Secret.Decrypt(ENCRYPTION)); + // Build the messages: + // - First of all the system prompt + // - Then none-empty user and AI messages + Messages = [systemPrompt, ..messages], - // Set the content: - request.Content = new StringContent(xChatRequest, Encoding.UTF8, "application/json"); - return request; - } - - await foreach (var content in this.StreamChatCompletionInternal("xAI", RequestBuilder, token)) + // Right now, we only support streaming completions: + Stream = true, + AdditionalApiParameters = apiParameters + }; + }, + token: token)) yield return content; } @@ -158,4 +134,4 @@ public sealed class ProviderX() : BaseProvider(LLMProviders.X, "https://api.x.ai } ]); } -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.3.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.3.1.md index 927a4917..cc2043b4 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.3.1.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.3.1.md @@ -26,6 +26,7 @@ - Improved the validation of additional API parameters in the advanced provider settings to help catch formatting mistakes earlier. - Improved the app startup resilience by allowing AI Studio to continue without Qdrant if it fails to initialize. - Improved the translation assistant by updating the system and user prompts. +- Improved OpenAI-compatible providers by refactoring their streaming request handling to be more consistent and reliable. - Fixed an issue where assistants hidden via configuration plugins still appear in "Send to ..." menus. Thanks, Gunnar, for reporting this issue. - Fixed an issue with chat templates that could stop working because the stored validation result for attached files was reused. AI Studio now checks attached files again when you use a chat template. - Fixed an issue with voice recording where AI Studio could log errors and keep the feature available even though required parts failed to initialize. Voice recording is now disabled automatically for the current session in that case.