Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions backend/src/Taskdeck.Api/Extensions/LlmProviderRegistration.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Net;
using System.Net.Sockets;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Polly;
Expand All @@ -11,6 +12,8 @@ namespace Taskdeck.Api.Extensions;

public static class LlmProviderRegistration
{
internal const string OpenAiCompatibleHttpClientName = "OpenAiCompatibleLlmProvider";

public static IServiceCollection AddLlmProviders(
this IServiceCollection services,
IConfiguration configuration)
Expand Down Expand Up @@ -78,13 +81,20 @@ public static IServiceCollection AddLlmProviders(
circuitBreakerTracker, "Gemini", circuitBreakerSettings);
var ollamaCircuitBreakerPolicy = BuildCircuitBreakerPolicy(
circuitBreakerTracker, "Ollama", circuitBreakerSettings);
var openAiCompatibleCircuitBreakerPolicy = BuildOpenAiCompatibleCircuitBreakerPolicy(
circuitBreakerTracker, "OpenAICompatible", circuitBreakerSettings);

// Determine once at startup whether localhost LLM endpoints are permitted.
// This is true only in development-like environments with AllowLiveProvidersInDevelopment.
var localhostPolicy = ResolveLocalhostPolicy(services, configuration);
services.AddSingleton(localhostPolicy);
services.TryAddTransient<ProtectedOutboundTelemetryHandler>();
services.TryAddSingleton<ProtectedOutboundMeterFactory>();
var egressRegistry = GetOrCreateEgressRegistry(services);
RegisterOpenAiCompatibleEgress(
egressRegistry,
llmProviderSettings,
localhostPolicy.AllowGeneralProviderLocalhost);

services.AddHttpClient<OpenAiLlmProvider>((sp, client) =>
{
Expand Down Expand Up @@ -114,6 +124,36 @@ public static IServiceCollection AddLlmProviders(
.AddPolicyHandler(openAiCircuitBreakerPolicy)
.RemoveAllLoggers()
.AddHttpMessageHandler<ProtectedOutboundTelemetryHandler>();
services.AddHttpClient(OpenAiCompatibleHttpClientName, (sp, client) =>
{
var settings = sp.GetRequiredService<LlmProviderSettings>();
var timeoutSeconds = settings.OpenAiCompatible?.TimeoutSeconds > 0 ? settings.OpenAiCompatible.TimeoutSeconds : 30;
client.Timeout = TimeSpan.FromSeconds(timeoutSeconds);
})
.ConfigurePrimaryHttpMessageHandler(serviceProvider =>
{
return new SocketsHttpHandler
{
AllowAutoRedirect = false,
UseProxy = false,
ActivityHeadersPropagator = null,
MeterFactory = serviceProvider.GetRequiredService<ProtectedOutboundMeterFactory>(),
ConnectCallback = (context, cancellationToken) =>
OutboundWebhookConnectCallback.ConnectAsync(
context,
allowLocalhostEndpoints: localhostPolicy.AllowGeneralProviderLocalhost,
cancellationToken)
};
})
.AddPolicyHandler(openAiCompatibleCircuitBreakerPolicy)
.RemoveAllLoggers()
.AddHttpMessageHandler<ProtectedOutboundTelemetryHandler>()
.AddHttpMessageHandler(sp => new EgressEnvelopeHandler(
sp.GetRequiredService<IEgressRegistry>(),
sp.GetRequiredService<ILogger<EgressEnvelopeHandler>>(),
nameof(OpenAiCompatibleLlmProvider),
followRedirects: false))
.AddHttpMessageHandler(_ => new LlmDispatchTrackingHandler());
services.AddHttpClient<GeminiLlmProvider>((sp, client) =>
{
var settings = sp.GetRequiredService<LlmProviderSettings>();
Expand Down Expand Up @@ -182,6 +222,12 @@ public static IServiceCollection AddLlmProviders(
return decision.ProviderKind switch
{
LlmProviderKind.OpenAi => sp.GetRequiredService<OpenAiLlmProvider>(),
LlmProviderKind.OpenAiCompatible => CreateOpenAiCompatibleProvider(
sp,
settings,
circuitBreakerTracker,
circuitBreakerSettings,
localhostPolicy),
LlmProviderKind.Gemini => sp.GetRequiredService<GeminiLlmProvider>(),
LlmProviderKind.Ollama => sp.GetRequiredService<OllamaLlmProvider>(),
_ => sp.GetRequiredService<MockLlmProvider>()
Expand All @@ -191,6 +237,62 @@ public static IServiceCollection AddLlmProviders(
return services;
}

private static OpenAiCompatibleLlmProvider CreateOpenAiCompatibleProvider(
IServiceProvider services,
LlmProviderSettings settings,
CircuitBreakerStateTracker circuitBreakerTracker,
CircuitBreakerSettings circuitBreakerSettings,
LlmProviderRuntimePolicy runtimePolicy)
{
RegisterOpenAiCompatibleEgress(
services.GetRequiredService<IEgressRegistry>(),
settings,
runtimePolicy.AllowGeneralProviderLocalhost);
return new OpenAiCompatibleLlmProvider(
services.GetRequiredService<IHttpClientFactory>().CreateClient(OpenAiCompatibleHttpClientName),
settings,
services.GetRequiredService<ILogger<OpenAiCompatibleLlmProvider>>(),
circuitBreakerTracker,
circuitBreakerSettings,
runtimePolicy);
}

private static IEgressRegistry GetOrCreateEgressRegistry(IServiceCollection services)
{
var existing = services.LastOrDefault(descriptor => descriptor.ServiceType == typeof(IEgressRegistry));
if (existing?.ImplementationInstance is IEgressRegistry registry)
return registry;

var created = new EgressRegistry();
if (existing is null)
services.AddSingleton<IEgressRegistry>(created);
return created;
}

internal static void RegisterOpenAiCompatibleEgress(
IEgressRegistry registry,
LlmProviderSettings settings,
bool allowLocalhostEndpoints)
{
if (!LlmProviderSelectionPolicy.TryValidateOpenAiCompatibleSettings(
settings,
out _,
allowLocalhostEndpoints) ||
!Uri.TryCreate(settings.OpenAiCompatible.BaseUrl, UriKind.Absolute, out var endpoint))
return;

if (registry.GetAllEntries().Any(entry =>
string.Equals(entry.Host.TrimEnd('.'), endpoint.Host.TrimEnd('.'), StringComparison.OrdinalIgnoreCase) &&
string.Equals(entry.ToolOrAgentName, nameof(OpenAiCompatibleLlmProvider), StringComparison.Ordinal)))
return;

registry.Register(new EgressEntry(
endpoint.Host,
"LLM prompt with board context and user input",
nameof(OpenAiCompatibleLlmProvider),
EgressDataClassification.UserContent));
}

/// <summary>
/// Builds a Polly circuit breaker policy for an external HTTP client.
/// The circuit opens after <see cref="CircuitBreakerSettings.FailureThreshold"/>
Expand Down Expand Up @@ -229,6 +331,34 @@ internal static IAsyncPolicy<HttpResponseMessage> BuildCircuitBreakerPolicy(
});
}

/// <summary>
/// Builds the compatible-provider policy. HTTP 501 is intentionally excluded:
/// the provider recognizes it as an SSE capability rejection and performs a
/// buffered retry, which must remain reachable even at a threshold of one.
/// </summary>
internal static IAsyncPolicy<HttpResponseMessage> BuildOpenAiCompatibleCircuitBreakerPolicy(
CircuitBreakerStateTracker tracker,
string circuitName,
CircuitBreakerSettings settings)
{
return Policy<HttpResponseMessage>
.Handle<HttpRequestException>()
.OrResult(response =>
response.StatusCode == HttpStatusCode.RequestTimeout ||
((int)response.StatusCode >= 500 &&
response.StatusCode != HttpStatusCode.NotImplemented))
.CircuitBreakerAsync(
handledEventsAllowedBeforeBreaking: settings.FailureThreshold,
durationOfBreak: TimeSpan.FromSeconds(settings.BreakDurationSeconds),
onBreak: (outcome, breakDuration) =>
{
var reason = outcome.Exception?.Message ?? $"HTTP {(int)(outcome.Result?.StatusCode ?? 0)}";
tracker.RecordState(circuitName, CircuitState.Open, reason);
},
onReset: () => tracker.RecordState(circuitName, CircuitState.Closed),
onHalfOpen: () => tracker.RecordState(circuitName, CircuitState.HalfOpen));
}

/// <summary>
/// Determines whether localhost LLM endpoints should be permitted based on the
/// hosting environment and LLM provider configuration. Returns true only in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ internal sealed class ProtectedOutboundSentryHttpMessageHandlerFilter : IHttpMes
private static readonly HashSet<string> ProtectedClientNames = new(StringComparer.Ordinal)
{
nameof(OpenAiLlmProvider),
LlmProviderRegistration.OpenAiCompatibleHttpClientName,
nameof(GeminiLlmProvider),
nameof(OllamaLlmProvider),
"OutboundWebhookDelivery"
Expand Down
1 change: 1 addition & 0 deletions backend/src/Taskdeck.Api/Extensions/WorkerRegistration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,5 @@ public static IServiceCollection AddTaskdeckWorkers(

return services;
}

}
10 changes: 10 additions & 0 deletions backend/src/Taskdeck.Api/appsettings.Development.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,16 @@
"Model": "gpt-4o-mini",
"TimeoutSeconds": 30
},
"OpenAiCompatible": {
"ApiKey": "",
"BaseUrl": "",
"Model": "",
"TimeoutSeconds": 30,
"MaxResponseBytes": 1048576,
"MaxSseLineBytes": 65536,
"MaxSseEventBytes": 131072,
"ExtraHeaders": {}
},
"Gemini": {
"ApiKey": "",
"BaseUrl": "https://generativelanguage.googleapis.com/v1beta",
Expand Down
10 changes: 10 additions & 0 deletions backend/src/Taskdeck.Api/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,16 @@
"Model": "gpt-4o-mini",
"TimeoutSeconds": 30
},
"OpenAiCompatible": {
"ApiKey": "",
"BaseUrl": "",
"Model": "",
"TimeoutSeconds": 30,
"MaxResponseBytes": 1048576,
"MaxSseLineBytes": 65536,
"MaxSseEventBytes": 131072,
"ExtraHeaders": {}
},
"Gemini": {
"ApiKey": "",
"BaseUrl": "https://generativelanguage.googleapis.com/v1beta",
Expand Down
Loading
Loading