Added Hetzner as an experimental LLM provider (#912)
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Read metadata (push) Blocked by required conditions
Build and Release / Sync Flatpak repo (push) Blocked by required conditions
Build and Release / Collect Flatpak artifacts (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-08-12 19:02:06 +02:00
1 parent bb8f6f13f0
commit e026c03d14
15 files changed
+226 -36

No files matched your search

+28 -26
View File
@@ -269,10 +269,10 @@ public abstract class BaseProvider : IProvider, ISecretId
{
exception = new();
if (!line.StartsWith("data: ", StringComparison.InvariantCulture))
if (!TryGetServerSentEventData(line, out var jsonData))
return false;
var jsonData = line[6..].Trim();
jsonData = jsonData.Trim();
if (string.IsNullOrWhiteSpace(jsonData) || jsonData is "[DONE]")
return false;
@@ -304,6 +304,21 @@ public abstract class BaseProvider : IProvider, ISecretId
}
}
private static bool TryGetServerSentEventData(string line, out string data)
{
const string DATA_PREFIX = "data:";
data = string.Empty;
if (!line.StartsWith(DATA_PREFIX, StringComparison.InvariantCulture))
return false;
data = line[DATA_PREFIX.Length..];
if (data.StartsWith(' '))
data = data[1..];
return true;
}
private static bool IsProviderStreamFailure(JsonElement root)
{
var eventType = TryGetString(root, "type");
@@ -661,13 +676,13 @@ public abstract class BaseProvider : IProvider, ISecretId
if (this.TryCreateProviderRequestExceptionFromStreamLine(providerName, line, out var providerRequestException))
throw providerRequestException;
// Skip lines that do not start with "data: ". Regard
// Skip lines that do not start with "data:". According
// to the specification, we only want to read the data lines:
if (!line.StartsWith("data: ", StringComparison.InvariantCulture))
if (!TryGetServerSentEventData(line, out var jsonData))
continue;
// Check if the line is the end of the stream:
if (line.StartsWith("data: [DONE]", StringComparison.InvariantCulture))
if (jsonData is "[DONE]")
yield break;
//
@@ -681,10 +696,6 @@ public abstract class BaseProvider : IProvider, ISecretId
try
{
// We know that the line starts with "data: ". Hence, we can
// skip the first 6 characters to get the JSON data after that.
var jsonData = line[6..];
// Deserialize the JSON data:
providerResponse = JsonSerializer.Deserialize<TAnnotation>(jsonData, JSON_SERIALIZER_OPTIONS);
@@ -713,10 +724,6 @@ public abstract class BaseProvider : IProvider, ISecretId
TDelta? providerResponse;
try
{
// We know that the line starts with "data: ". Hence, we can
// skip the first 6 characters to get the JSON data after that.
var jsonData = line[6..];
// Deserialize the JSON data:
providerResponse = JsonSerializer.Deserialize<TDelta>(jsonData, JSON_SERIALIZER_OPTIONS);
@@ -866,20 +873,19 @@ public abstract class BaseProvider : IProvider, ISecretId
if (line.StartsWith("event: response.completed", StringComparison.InvariantCulture))
yield break;
if (!TryGetServerSentEventData(line, out var jsonData))
continue;
//
// Find delta lines:
//
if (line.StartsWith("""
data: {"type":"response.output_text.delta"
""", StringComparison.InvariantCulture))
if (jsonData.StartsWith("""
{"type":"response.output_text.delta"
""", StringComparison.InvariantCulture))
{
TDelta? providerResponse;
try
{
// We know that the line starts with "data: ". Hence, we can
// skip the first 6 characters to get the JSON data after that.
var jsonData = line[6..];
// Deserialize the JSON data:
providerResponse = JsonSerializer.Deserialize<TDelta>(jsonData, JSON_SERIALIZER_OPTIONS);
@@ -903,18 +909,14 @@ public abstract class BaseProvider : IProvider, ISecretId
//
// Find annotation added lines:
//
else if (annotationSupported && line.StartsWith(
else if (annotationSupported && jsonData.StartsWith(
"""
data: {"type":"response.output_text.annotation.added"
{"type":"response.output_text.annotation.added"
""", StringComparison.InvariantCulture))
{
TAnnotation? providerResponse;
try
{
// We know that the line starts with "data: ". Hence, we can
// skip the first 6 characters to get the JSON data after that.
var jsonData = line[6..];
// Deserialize the JSON data:
providerResponse = JsonSerializer.Deserialize<TAnnotation>(jsonData, JSON_SERIALIZER_OPTIONS);
@@ -64,6 +64,12 @@ public sealed record Confidence
Level = ConfidenceLevel.MEDIUM,
Description = TB("The provider is located in the EU and is subject to the **GDPR** (General Data Protection Regulation). Additionally, the provider states that **your data is not used for training**."),
};
public static readonly Confidence GDPR_EXPERIMENTAL_OPEN_SOURCE = new()
{
Level = ConfidenceLevel.MEDIUM,
Description = TB("The provider operates its service in the EU and is subject to the **GDPR** (General Data Protection Regulation). It provides access to **open source models**. However, the service is currently **experimental**, and performance and availability are not guaranteed. We have no provider-specific information about whether submitted data is used for training."),
};
public static readonly Confidence SELF_HOSTED = new()
{
@@ -0,0 +1,93 @@
using System.Runtime.CompilerServices;
using AIStudio.Chat;
using AIStudio.Provider.OpenAI;
using AIStudio.Settings;
namespace AIStudio.Provider.Hetzner;
public sealed class ProviderHetzner() : BaseProvider(LLMProviders.HETZNER, new Uri("https://inference.hetzner.com/api/v1/"), ExternalHttpTrustPolicy.SYSTEM_TRUST_ONLY, LOGGER)
{
private static readonly ILogger<ProviderHetzner> LOGGER = Program.LOGGER_FACTORY.CreateLogger<ProviderHetzner>();
#region Implementation of IProvider
/// <inheritdoc />
public override string Id => LLMProviders.HETZNER.ToSecretId();
/// <inheritdoc />
public override string InstanceName { get; set; } = "Hetzner (Experimental)";
/// <inheritdoc />
public override bool HasModelLoadingCapability => true;
/// <inheritdoc />
public override async IAsyncEnumerable<ContentStreamChunk> StreamChatCompletion(Model chatModel, ChatThread chatThread, SettingsManager settingsManager, [EnumeratorCancellation] CancellationToken token = default)
{
await foreach (var content in this.StreamOpenAICompatibleChatCompletion<ChatCompletionAPIRequest, ChatCompletionDeltaStreamLine, NoChatCompletionAnnotationStreamLine>(
"Hetzner",
chatModel,
chatThread,
settingsManager,
async (systemPrompt, apiParameters) =>
{
var messages = await chatThread.Blocks.BuildMessagesUsingNestedImageUrlAsync(this.Provider, chatModel);
return new ChatCompletionAPIRequest
{
Model = chatModel.Id,
Messages = [systemPrompt, ..messages],
Stream = true,
AdditionalApiParameters = apiParameters
};
},
token: token))
yield return content;
}
#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)
{
yield break;
}
#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)
{
return Task.FromResult(TranscriptionResult.Failure());
}
/// <inheritdoc />
public override Task<IReadOnlyList<IReadOnlyList<float>>> EmbedTextAsync(Model embeddingModel, SettingsManager settingsManager, CancellationToken token = default, params List<string> texts)
{
return Task.FromResult<IReadOnlyList<IReadOnlyList<float>>>([]);
}
/// <inheritdoc />
public override Task<ModelLoadResult> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default)
{
return this.LoadModelsResponse<ModelsResponse>(SecretStoreType.LLM_PROVIDER, "models", modelResponse => modelResponse.Data, token, apiKeyProvisional);
}
/// <inheritdoc />
public override Task<ModelLoadResult> GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default)
{
return Task.FromResult(ModelLoadResult.FromModels([]));
}
/// <inheritdoc />
public override Task<ModelLoadResult> GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default)
{
return Task.FromResult(ModelLoadResult.FromModels([]));
}
/// <inheritdoc />
public override Task<ModelLoadResult> GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default)
{
return Task.FromResult(ModelLoadResult.FromModels([]));
}
#endregion
}
@@ -16,6 +16,7 @@ public enum LLMProviders
ALIBABA_CLOUD = 12,
PERPLEXITY = 14,
OPEN_ROUTER = 15,
HETZNER = 16,
FIREWORKS = 5,
GROQ = 6,
@@ -6,6 +6,7 @@ using AIStudio.Provider.Google;
using AIStudio.Provider.Groq;
using AIStudio.Provider.GWDG;
using AIStudio.Provider.Helmholtz;
using AIStudio.Provider.Hetzner;
using AIStudio.Provider.HuggingFace;
using AIStudio.Provider.Mistral;
using AIStudio.Provider.OpenAI;
@@ -56,6 +57,7 @@ public static class LLMProvidersExtensions
LLMProviders.ALIBABA_CLOUD => "Alibaba Cloud",
LLMProviders.PERPLEXITY => "Perplexity",
LLMProviders.OPEN_ROUTER => "OpenRouter",
LLMProviders.HETZNER => "Hetzner (Experimental)",
LLMProviders.GROQ => "Groq",
LLMProviders.FIREWORKS => "Fireworks.ai",
@@ -91,6 +93,7 @@ public static class LLMProvidersExtensions
LLMProviders.ALIBABA_CLOUD => "Alibaba Cloud",
LLMProviders.PERPLEXITY => "Perplexity",
LLMProviders.OPEN_ROUTER => "OpenRouter",
LLMProviders.HETZNER => "Hetzner",
LLMProviders.GROQ => "Groq",
LLMProviders.FIREWORKS => "Fireworks.ai",
@@ -144,6 +147,12 @@ public static class LLMProvidersExtensions
LLMProviders.OPEN_ROUTER => Confidence.USA_HUB.WithRegion("America, U.S.").WithSources("https://openrouter.ai/privacy", "https://openrouter.ai/terms").WithLevel(settingsManager.GetConfiguredConfidenceLevel(llmProvider)),
LLMProviders.HETZNER => Confidence.GDPR_EXPERIMENTAL_OPEN_SOURCE.WithRegion("Europe, Germany").WithSources(
"https://experiments.hetzner.com/docs/inference",
"https://www.hetzner.com/legal/privacy-policy/",
"https://www.hetzner.com/legal/terms-and-conditions/"
).WithLevel(settingsManager.GetConfiguredConfidenceLevel(llmProvider)),
LLMProviders.SELF_HOSTED => Confidence.SELF_HOSTED.WithLevel(settingsManager.GetConfiguredConfidenceLevel(llmProvider)),
LLMProviders.HELMHOLTZ => Confidence.GDPR_NO_TRAINING.WithRegion("Europe, Germany").WithSources("https://helmholtz.cloud/services/?serviceID=d7d5c597-a2f6-4bd1-b71e-4d6499d98570").WithLevel(settingsManager.GetConfiguredConfidenceLevel(llmProvider)),
@@ -180,6 +189,7 @@ public static class LLMProvidersExtensions
LLMProviders.HUGGINGFACE => false,
LLMProviders.PERPLEXITY => false,
LLMProviders.OPEN_ROUTER => true,
LLMProviders.HETZNER => false,
//
// Self-hosted providers are treated as a special case anyway.
@@ -209,6 +219,7 @@ public static class LLMProvidersExtensions
// Providers that do not support transcription:
//
LLMProviders.OPEN_ROUTER => false,
LLMProviders.HETZNER => false,
LLMProviders.GROQ => false,
LLMProviders.ANTHROPIC => false,
LLMProviders.X => false,
@@ -271,6 +282,7 @@ public static class LLMProvidersExtensions
LLMProviders.ALIBABA_CLOUD => new ProviderAlibabaCloud { InstanceName = instanceName, ConfiguredProviderId = configuredProviderId, AdditionalJsonApiParameters = expertProviderApiParameter, IsEnterpriseConfiguration = isEnterpriseConfiguration },
LLMProviders.PERPLEXITY => new ProviderPerplexity { InstanceName = instanceName, ConfiguredProviderId = configuredProviderId, AdditionalJsonApiParameters = expertProviderApiParameter, IsEnterpriseConfiguration = isEnterpriseConfiguration },
LLMProviders.OPEN_ROUTER => new ProviderOpenRouter { InstanceName = instanceName, ConfiguredProviderId = configuredProviderId, AdditionalJsonApiParameters = expertProviderApiParameter, IsEnterpriseConfiguration = isEnterpriseConfiguration },
LLMProviders.HETZNER => new ProviderHetzner { InstanceName = instanceName, ConfiguredProviderId = configuredProviderId, AdditionalJsonApiParameters = expertProviderApiParameter, IsEnterpriseConfiguration = isEnterpriseConfiguration },
LLMProviders.GROQ => new ProviderGroq { InstanceName = instanceName, ConfiguredProviderId = configuredProviderId, AdditionalJsonApiParameters = expertProviderApiParameter, IsEnterpriseConfiguration = isEnterpriseConfiguration },
LLMProviders.FIREWORKS => new ProviderFireworks { InstanceName = instanceName, ConfiguredProviderId = configuredProviderId, AdditionalJsonApiParameters = expertProviderApiParameter, IsEnterpriseConfiguration = isEnterpriseConfiguration },
@@ -302,6 +314,7 @@ public static class LLMProvidersExtensions
LLMProviders.ALIBABA_CLOUD => "https://account.alibabacloud.com/register/intl_register.htm",
LLMProviders.PERPLEXITY => "https://www.perplexity.ai/account/api",
LLMProviders.OPEN_ROUTER => "https://openrouter.ai/keys",
LLMProviders.HETZNER => "https://experiments.hetzner.com",
LLMProviders.GROQ => "https://console.groq.com/",
LLMProviders.FIREWORKS => "https://fireworks.ai/login",
@@ -327,6 +340,7 @@ public static class LLMProvidersExtensions
LLMProviders.PERPLEXITY => "https://www.perplexity.ai/account/api/",
LLMProviders.OPEN_ROUTER => "https://openrouter.ai/activity",
LLMProviders.HUGGINGFACE => "https://huggingface.co/settings/billing",
LLMProviders.HETZNER => "https://experiments.hetzner.com",
_ => string.Empty,
};
@@ -345,6 +359,7 @@ public static class LLMProvidersExtensions
LLMProviders.PERPLEXITY => true,
LLMProviders.OPEN_ROUTER => true,
LLMProviders.HUGGINGFACE => true,
LLMProviders.HETZNER => true,
_ => false,
};
@@ -422,6 +437,7 @@ public static class LLMProvidersExtensions
LLMProviders.ALIBABA_CLOUD => true,
LLMProviders.PERPLEXITY => true,
LLMProviders.OPEN_ROUTER => true,
LLMProviders.HETZNER => true,
LLMProviders.GROQ => true,
LLMProviders.FIREWORKS => true,
@@ -445,6 +461,7 @@ public static class LLMProvidersExtensions
LLMProviders.ALIBABA_CLOUD => true,
LLMProviders.PERPLEXITY => true,
LLMProviders.OPEN_ROUTER => true,
LLMProviders.HETZNER => true,
LLMProviders.GROQ => true,
LLMProviders.FIREWORKS => true,