mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-12 19:53:48 +00:00
Added transcription for Helmholtz and Groq, embeddings for GWDG, and fixed Mistral model capabilities (#942)
This commit is contained in:
1 parent
53da222562
commit
2965102c53
13 files changed
+342
-131
No files matched your search
@@ -375,7 +375,36 @@ public abstract class BaseProvider : IProvider, ISecretId
|
||||
|
||||
errorCode = TryGetString(root, "code");
|
||||
errorType = TryGetString(root, "type");
|
||||
errorMessage = TryGetString(root, "message");
|
||||
|
||||
// Services built on FastAPI, such as Helmholtz Blablador, word their errors as "detail":
|
||||
errorMessage = TryGetString(root, "message") ?? TryGetString(root, "detail");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the error message a provider sent in the body of a failed response.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Providers word their errors differently, but they all put a sentence somewhere into the
|
||||
/// body. Passing that sentence on is what lets a user act on the problem instead of only
|
||||
/// learning that something went wrong.
|
||||
/// </remarks>
|
||||
/// <param name="responseBody">The body of the failed response.</param>
|
||||
/// <returns>The message, or an empty string when the body carries none.</returns>
|
||||
private static string ReadProviderErrorMessage(string responseBody)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(responseBody))
|
||||
return string.Empty;
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(responseBody);
|
||||
TryGetProviderStreamError(document.RootElement, out _, out _, out var errorMessage);
|
||||
return errorMessage ?? string.Empty;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryGetErrorElement(JsonElement root, out JsonElement errorElement)
|
||||
@@ -1013,6 +1042,35 @@ public abstract class BaseProvider : IProvider, ISecretId
|
||||
yield return content;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the message a user gets to see when a transcription request failed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// AI Studio always sends WebM/Opus. Some providers run their speech recognition behind a
|
||||
/// decoder which reads WAV only, and they answer with a bad request whose body says that it
|
||||
/// could not decode the file. Nobody can do anything about that inside AI Studio, so we name
|
||||
/// the likely cause and point at the provider instead of showing the raw message.
|
||||
/// </remarks>
|
||||
/// <param name="statusCode">The status code the provider answered with.</param>
|
||||
/// <param name="responseBody">The body the provider answered with.</param>
|
||||
/// <returns>The message to show, or an empty string when we have nothing to say.</returns>
|
||||
private string GetTranscriptionFailureUserMessage(HttpStatusCode statusCode, string responseBody)
|
||||
{
|
||||
var failureReason = this.ClassifyProviderRequestFailure(statusCode, responseBody);
|
||||
var classifiedMessage = this.GetProviderRequestFailureUserMessage(failureReason);
|
||||
if (!string.IsNullOrWhiteSpace(classifiedMessage))
|
||||
return classifiedMessage;
|
||||
|
||||
if (statusCode is HttpStatusCode.BadRequest && responseBody.Contains("not decode", StringComparison.OrdinalIgnoreCase))
|
||||
return string.Format(TB("The provider '{0}' was not able to read the audio file. It probably does not support the WebM/Opus format which AI Studio sends. Please contact the provider about it."), this.InstanceName);
|
||||
|
||||
var providerMessage = ReadProviderErrorMessage(responseBody);
|
||||
if (!string.IsNullOrWhiteSpace(providerMessage))
|
||||
return string.Format(TB("The provider '{0}' reported an error: {1}"), this.InstanceName, providerMessage);
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
protected async Task<TranscriptionResult> PerformStandardTranscriptionRequest(RequestedSecret requestedSecret, Model transcriptionModel, string audioFilePath, Host host = Host.NONE, CancellationToken token = default)
|
||||
{
|
||||
try
|
||||
@@ -1082,8 +1140,7 @@ public abstract class BaseProvider : IProvider, ISecretId
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
this.logger.LogError("Transcription request failed with status code {ResponseStatusCode} and body: '{ResponseBody}'.", response.StatusCode, responseBody);
|
||||
var providerRequestFailure = this.ClassifyProviderRequestFailure(response.StatusCode, responseBody);
|
||||
return TranscriptionResult.Failure(this.GetProviderRequestFailureUserMessage(providerRequestFailure));
|
||||
return TranscriptionResult.Failure(this.GetTranscriptionFailureUserMessage(response.StatusCode, responseBody));
|
||||
}
|
||||
|
||||
var transcriptionResponse = JsonSerializer.Deserialize<TranscriptionResponse>(responseBody, JSON_SERIALIZER_OPTIONS);
|
||||
@@ -1162,6 +1219,15 @@ public abstract class BaseProvider : IProvider, ISecretId
|
||||
this.logger.LogError("Embedding request failed with status code {ResponseStatusCode} and body: '{ResponseBody}'.", response.StatusCode, responseBody);
|
||||
var providerRequestFailure = this.ClassifyProviderRequestFailure(response.StatusCode, responseBody);
|
||||
var userMessage = this.GetProviderRequestFailureUserMessage(providerRequestFailure);
|
||||
|
||||
// We know nothing about this failure, so we pass on what the provider said about it:
|
||||
if (string.IsNullOrWhiteSpace(userMessage))
|
||||
{
|
||||
var providerMessage = ReadProviderErrorMessage(responseBody);
|
||||
if (!string.IsNullOrWhiteSpace(providerMessage))
|
||||
userMessage = string.Format(TB("The provider '{0}' reported an error: {1}"), this.InstanceName, providerMessage);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(userMessage))
|
||||
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.CloudOff, userMessage));
|
||||
|
||||
@@ -1214,7 +1280,7 @@ public abstract class BaseProvider : IProvider, ISecretId
|
||||
|
||||
protected static bool TryPopIntParameter(IDictionary<string, object> parameters, string key, out int value)
|
||||
{
|
||||
value = default;
|
||||
value = 0;
|
||||
if (!TryPopParameter(parameters, key, out var raw) || raw is null)
|
||||
return false;
|
||||
|
||||
@@ -1224,15 +1290,15 @@ public abstract class BaseProvider : IProvider, ISecretId
|
||||
value = i;
|
||||
return true;
|
||||
|
||||
case long l when l is >= int.MinValue and <= int.MaxValue:
|
||||
case long l and >= int.MinValue and <= int.MaxValue:
|
||||
value = (int)l;
|
||||
return true;
|
||||
|
||||
case double d when d is >= int.MinValue and <= int.MaxValue:
|
||||
case double d and >= int.MinValue and <= int.MaxValue:
|
||||
value = (int)d;
|
||||
return true;
|
||||
|
||||
case decimal m when m is >= int.MinValue and <= int.MaxValue:
|
||||
case decimal m and >= int.MinValue and <= int.MaxValue:
|
||||
value = (int)m;
|
||||
return true;
|
||||
}
|
||||
@@ -1242,7 +1308,7 @@ public abstract class BaseProvider : IProvider, ISecretId
|
||||
|
||||
protected static bool TryPopBoolParameter(IDictionary<string, object> parameters, string key, out bool value)
|
||||
{
|
||||
value = default;
|
||||
value = false;
|
||||
if (!TryPopParameter(parameters, key, out var raw) || raw is null)
|
||||
return false;
|
||||
|
||||
|
||||
@@ -10,6 +10,14 @@ public sealed class ProviderGWDG() : BaseProvider(LLMProviders.GWDG, new Uri("ht
|
||||
{
|
||||
private static readonly ILogger<ProviderGWDG> LOGGER = Program.LOGGER_FACTORY.CreateLogger<ProviderGWDG>();
|
||||
|
||||
// Source: https://docs.hpc.gwdg.de/services/saia/index.html#embeddings
|
||||
private static readonly Model[] KNOWN_EMBEDDING_MODELS =
|
||||
[
|
||||
new("e5-mistral-7b-instruct", "E5 Mistral 7B Instruct"),
|
||||
new("multilingual-e5-large-instruct", "Multilingual E5 Large Instruct"),
|
||||
new("qwen3-embedding-4b", "Qwen3 Embedding 4B"),
|
||||
];
|
||||
|
||||
#region Implementation of IProvider
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -67,9 +75,10 @@ public sealed class ProviderGWDG() : BaseProvider(LLMProviders.GWDG, new Uri("ht
|
||||
}
|
||||
|
||||
/// <inhertidoc />
|
||||
public override Task<IReadOnlyList<IReadOnlyList<float>>> EmbedTextAsync(Model embeddingModel, SettingsManager settingsManager, CancellationToken token = default, params List<string> texts)
|
||||
public override async Task<IReadOnlyList<IReadOnlyList<float>>> EmbedTextAsync(Model embeddingModel, SettingsManager settingsManager, CancellationToken token = default, params List<string> texts)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<IReadOnlyList<float>>>([]);
|
||||
var requestedSecret = await Program.RUST_SERVICE.GetAPIKey(this, SecretStoreType.EMBEDDING_PROVIDER);
|
||||
return await this.PerformStandardTextEmbeddingRequest(requestedSecret, embeddingModel, token: token, texts: texts);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -89,12 +98,26 @@ public sealed class ProviderGWDG() : BaseProvider(LLMProviders.GWDG, new Uri("ht
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// SAIA answers the models endpoint with its chat models only, so asking it for the embedding
|
||||
/// models comes back empty. We therefore fall back to the models the documentation names. The
|
||||
/// endpoint is still asked first: should SAIA start reporting them one day, its answer wins
|
||||
/// over our list. A failed request is passed on unchanged, so a wrong API key stays visible
|
||||
/// as such instead of being covered up by the fallback.
|
||||
/// </remarks>
|
||||
public override async Task<ModelLoadResult> GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default)
|
||||
{
|
||||
var result = await this.LoadModels(SecretStoreType.EMBEDDING_PROVIDER, token, apiKeyProvisional);
|
||||
if (!result.Success)
|
||||
return result;
|
||||
|
||||
var embeddingModels = result.Models.Where(model => model.IsEmbeddingModel()).ToList();
|
||||
if (embeddingModels.Count is 0)
|
||||
return ModelLoadResult.FromModels(KNOWN_EMBEDDING_MODELS);
|
||||
|
||||
return result with
|
||||
{
|
||||
Models = [..result.Models.Where(model => model.IsEmbeddingModel())]
|
||||
Models = [..embeddingModels]
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -64,9 +64,10 @@ public class ProviderGroq() : BaseProvider(LLMProviders.GROQ, new Uri("https://a
|
||||
#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<TranscriptionResult> TranscribeAudioAsync(Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default)
|
||||
public override async Task<TranscriptionResult> TranscribeAudioAsync(Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default)
|
||||
{
|
||||
return Task.FromResult(TranscriptionResult.Failure());
|
||||
var requestedSecret = await Program.RUST_SERVICE.GetAPIKey(this, SecretStoreType.TRANSCRIPTION_PROVIDER);
|
||||
return await this.PerformStandardTranscriptionRequest(requestedSecret, transcriptionModel, audioFilePath, token: token);
|
||||
}
|
||||
|
||||
/// <inhertidoc />
|
||||
@@ -76,9 +77,13 @@ public class ProviderGroq() : BaseProvider(LLMProviders.GROQ, new Uri("https://a
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<ModelLoadResult> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default)
|
||||
public override async Task<ModelLoadResult> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default)
|
||||
{
|
||||
return this.LoadModels(SecretStoreType.LLM_PROVIDER, token, apiKeyProvisional);
|
||||
var result = await this.LoadModels(SecretStoreType.LLM_PROVIDER, token, apiKeyProvisional);
|
||||
return result with
|
||||
{
|
||||
Models = [..result.Models.Where(model => model.IsChatModel())]
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -94,9 +99,13 @@ public class ProviderGroq() : BaseProvider(LLMProviders.GROQ, new Uri("https://a
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<ModelLoadResult> GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default)
|
||||
public override async Task<ModelLoadResult> GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default)
|
||||
{
|
||||
return Task.FromResult(ModelLoadResult.FromModels([]));
|
||||
var result = await this.LoadModels(SecretStoreType.TRANSCRIPTION_PROVIDER, token, apiKeyProvisional);
|
||||
return result with
|
||||
{
|
||||
Models = [..result.Models.Where(model => model.IsTranscriptionModel())]
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -106,10 +115,7 @@ public class ProviderGroq() : BaseProvider(LLMProviders.GROQ, new Uri("https://a
|
||||
return this.LoadModelsResponse<ModelsResponse>(
|
||||
storeType,
|
||||
"models",
|
||||
modelResponse => modelResponse.Data.Where(n =>
|
||||
!n.Id.StartsWith("whisper-", StringComparison.OrdinalIgnoreCase) &&
|
||||
!n.Id.StartsWith("distil-", StringComparison.OrdinalIgnoreCase) &&
|
||||
!n.Id.Contains("-tts", StringComparison.OrdinalIgnoreCase)),
|
||||
modelResponse => modelResponse.Data,
|
||||
token,
|
||||
apiKeyProvisional);
|
||||
}
|
||||
|
||||
@@ -62,9 +62,10 @@ public sealed class ProviderHelmholtz() : BaseProvider(LLMProviders.HELMHOLTZ, n
|
||||
#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<TranscriptionResult> TranscribeAudioAsync(Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default)
|
||||
public override async Task<TranscriptionResult> TranscribeAudioAsync(Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default)
|
||||
{
|
||||
return Task.FromResult(TranscriptionResult.Failure());
|
||||
var requestedSecret = await Program.RUST_SERVICE.GetAPIKey(this, SecretStoreType.TRANSCRIPTION_PROVIDER);
|
||||
return await this.PerformStandardTranscriptionRequest(requestedSecret, transcriptionModel, audioFilePath, token: token);
|
||||
}
|
||||
|
||||
/// <inhertidoc />
|
||||
@@ -107,9 +108,16 @@ public sealed class ProviderHelmholtz() : BaseProvider(LLMProviders.HELMHOLTZ, n
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<ModelLoadResult> GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default)
|
||||
public override async Task<ModelLoadResult> GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default)
|
||||
{
|
||||
return Task.FromResult(ModelLoadResult.FromModels([]));
|
||||
var result = await this.LoadModels(SecretStoreType.TRANSCRIPTION_PROVIDER, token, apiKeyProvisional);
|
||||
return result with
|
||||
{
|
||||
Models =
|
||||
[
|
||||
..result.Models.Where(model => model.IsTranscriptionModel())
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -185,7 +185,9 @@ public static class LLMProvidersExtensions
|
||||
LLMProviders.HELMHOLTZ => true,
|
||||
LLMProviders.ALIBABA_CLOUD => true,
|
||||
LLMProviders.IONOS => true,
|
||||
|
||||
LLMProviders.GWDG => true,
|
||||
LLMProviders.OPEN_ROUTER => true,
|
||||
|
||||
//
|
||||
// Providers that do not support embeddings:
|
||||
//
|
||||
@@ -193,11 +195,9 @@ public static class LLMProvidersExtensions
|
||||
LLMProviders.ANTHROPIC => false,
|
||||
LLMProviders.FIREWORKS => false,
|
||||
LLMProviders.X => false,
|
||||
LLMProviders.GWDG => false,
|
||||
LLMProviders.DEEP_SEEK => false,
|
||||
LLMProviders.HUGGINGFACE => false,
|
||||
LLMProviders.PERPLEXITY => false,
|
||||
LLMProviders.OPEN_ROUTER => true,
|
||||
LLMProviders.HETZNER => false,
|
||||
|
||||
//
|
||||
@@ -217,7 +217,9 @@ public static class LLMProvidersExtensions
|
||||
LLMProviders.MISTRAL => true,
|
||||
LLMProviders.FIREWORKS => true,
|
||||
LLMProviders.GWDG => true,
|
||||
|
||||
LLMProviders.HELMHOLTZ => true,
|
||||
LLMProviders.GROQ => true,
|
||||
|
||||
//
|
||||
// Providers that support transcription but provide no OpenAI-compatible API yet:
|
||||
//
|
||||
@@ -230,20 +232,17 @@ public static class LLMProvidersExtensions
|
||||
LLMProviders.OPEN_ROUTER => false,
|
||||
LLMProviders.HETZNER => false,
|
||||
LLMProviders.IONOS => false,
|
||||
LLMProviders.GROQ => false,
|
||||
LLMProviders.ANTHROPIC => false,
|
||||
LLMProviders.X => false,
|
||||
LLMProviders.DEEP_SEEK => false,
|
||||
LLMProviders.HUGGINGFACE => false,
|
||||
LLMProviders.PERPLEXITY => false,
|
||||
|
||||
LLMProviders.HELMHOLTZ => false,
|
||||
|
||||
//
|
||||
// Self-hosted providers are treated as a special case anyway.
|
||||
//
|
||||
LLMProviders.SELF_HOSTED => true,
|
||||
|
||||
|
||||
_ => false,
|
||||
};
|
||||
|
||||
|
||||
@@ -105,6 +105,13 @@ public static class ModelKindExtensions
|
||||
/// <returns>True, when the model is an embedding model.</returns>
|
||||
public static bool IsEmbeddingModel(this Model model) => model.DetermineKind() is ModelKind.EMBEDDING;
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether this model transcribes audio.
|
||||
/// </summary>
|
||||
/// <param name="model">The model to check.</param>
|
||||
/// <returns>True, when the model is a transcription model.</returns>
|
||||
public static bool IsTranscriptionModel(this Model model) => model.DetermineKind() is ModelKind.TRANSCRIPTION;
|
||||
|
||||
private static bool HasAnyMarker(string modelId, string[] markers)
|
||||
{
|
||||
foreach (var marker in markers)
|
||||
|
||||
Reference in new issue
Block a user