audit notes work completed, test fixes work (95% done), new sprints, new data sources setup and configuration

This commit is contained in:
master
2026-01-14 10:48:00 +02:00
parent d7be6ba34b
commit 95d5898650
379 changed files with 40695 additions and 19041 deletions

View File

@@ -0,0 +1,575 @@
using System.Net.Http.Json;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace StellaOps.AdvisoryAI.Inference.LlmProviders;
/// <summary>
/// Google Gemini LLM provider configuration (maps to gemini.yaml).
/// </summary>
public sealed class GeminiConfig : LlmProviderConfigBase
{
/// <summary>
/// API key (or use GEMINI_API_KEY or GOOGLE_API_KEY env var).
/// </summary>
public string? ApiKey { get; set; }
/// <summary>
/// Base URL for API requests.
/// </summary>
public string BaseUrl { get; set; } = "https://generativelanguage.googleapis.com/v1beta";
/// <summary>
/// Model name.
/// </summary>
public string Model { get; set; } = "gemini-1.5-flash";
/// <summary>
/// Fallback models.
/// </summary>
public List<string> FallbackModels { get; set; } = new();
/// <summary>
/// Top-p sampling.
/// </summary>
public double TopP { get; set; } = 1.0;
/// <summary>
/// Top-k sampling.
/// </summary>
public int TopK { get; set; } = 40;
/// <summary>
/// Log request/response bodies.
/// </summary>
public bool LogBodies { get; set; } = false;
/// <summary>
/// Log token usage.
/// </summary>
public bool LogUsage { get; set; } = true;
/// <summary>
/// Bind configuration from IConfiguration.
/// </summary>
public static GeminiConfig FromConfiguration(IConfiguration config)
{
var result = new GeminiConfig();
// Provider section
result.Enabled = config.GetValue("enabled", true);
result.Priority = config.GetValue("priority", 100);
// API section
var api = config.GetSection("api");
result.ApiKey = ExpandEnvVar(api.GetValue<string>("apiKey"));
result.BaseUrl = api.GetValue("baseUrl", "https://generativelanguage.googleapis.com/v1beta")!;
// Model section
var model = config.GetSection("model");
result.Model = model.GetValue("name", "gemini-1.5-flash")!;
result.FallbackModels = model.GetSection("fallbacks").Get<List<string>>() ?? new();
// Inference section
var inference = config.GetSection("inference");
result.Temperature = inference.GetValue("temperature", 0.0);
result.MaxTokens = inference.GetValue("maxTokens", 8192);
result.Seed = inference.GetValue<int?>("seed");
result.TopP = inference.GetValue("topP", 1.0);
result.TopK = inference.GetValue("topK", 40);
// Request section
var request = config.GetSection("request");
result.Timeout = request.GetValue("timeout", TimeSpan.FromSeconds(120));
result.MaxRetries = request.GetValue("maxRetries", 3);
// Logging section
var logging = config.GetSection("logging");
result.LogBodies = logging.GetValue("logBodies", false);
result.LogUsage = logging.GetValue("logUsage", true);
return result;
}
private static string? ExpandEnvVar(string? value)
{
if (string.IsNullOrEmpty(value))
{
return value;
}
// Expand ${VAR_NAME} pattern
if (value.StartsWith("${") && value.EndsWith("}"))
{
var varName = value.Substring(2, value.Length - 3);
return Environment.GetEnvironmentVariable(varName);
}
return Environment.ExpandEnvironmentVariables(value);
}
}
/// <summary>
/// Google Gemini LLM provider plugin.
/// </summary>
public sealed class GeminiLlmProviderPlugin : ILlmProviderPlugin
{
public string Name => "Gemini LLM Provider";
public string ProviderId => "gemini";
public string DisplayName => "Google Gemini";
public string Description => "Google Gemini models via Generative Language API";
public string DefaultConfigFileName => "gemini.yaml";
public bool IsAvailable(IServiceProvider services)
{
// Plugin is always available if the assembly is loaded
return true;
}
public ILlmProvider Create(IServiceProvider services, IConfiguration configuration)
{
var config = GeminiConfig.FromConfiguration(configuration);
var httpClientFactory = services.GetRequiredService<IHttpClientFactory>();
var loggerFactory = services.GetRequiredService<ILoggerFactory>();
return new GeminiLlmProvider(
httpClientFactory.CreateClient("Gemini"),
config,
loggerFactory.CreateLogger<GeminiLlmProvider>());
}
public LlmProviderConfigValidation ValidateConfiguration(IConfiguration configuration)
{
var errors = new List<string>();
var warnings = new List<string>();
var config = GeminiConfig.FromConfiguration(configuration);
if (!config.Enabled)
{
return LlmProviderConfigValidation.WithWarnings("Provider is disabled");
}
// Check API key
var apiKey = config.ApiKey
?? Environment.GetEnvironmentVariable("GEMINI_API_KEY")
?? Environment.GetEnvironmentVariable("GOOGLE_API_KEY");
if (string.IsNullOrEmpty(apiKey))
{
errors.Add("API key not configured. Set 'api.apiKey' or GEMINI_API_KEY/GOOGLE_API_KEY environment variable.");
}
// Check base URL
if (string.IsNullOrEmpty(config.BaseUrl))
{
errors.Add("Base URL is required.");
}
else if (!Uri.TryCreate(config.BaseUrl, UriKind.Absolute, out _))
{
errors.Add($"Invalid base URL: {config.BaseUrl}");
}
// Check model
if (string.IsNullOrEmpty(config.Model))
{
warnings.Add("No model specified, will use default 'gemini-1.5-flash'.");
}
if (errors.Count > 0)
{
return new LlmProviderConfigValidation
{
IsValid = false,
Errors = errors,
Warnings = warnings
};
}
return new LlmProviderConfigValidation
{
IsValid = true,
Warnings = warnings
};
}
}
/// <summary>
/// Google Gemini LLM provider implementation.
/// </summary>
public sealed class GeminiLlmProvider : ILlmProvider
{
private readonly HttpClient _httpClient;
private readonly GeminiConfig _config;
private readonly ILogger<GeminiLlmProvider> _logger;
private readonly string _apiKey;
private bool _disposed;
public string ProviderId => "gemini";
public GeminiLlmProvider(
HttpClient httpClient,
GeminiConfig config,
ILogger<GeminiLlmProvider> logger)
{
_httpClient = httpClient;
_config = config;
_logger = logger;
_apiKey = config.ApiKey
?? Environment.GetEnvironmentVariable("GEMINI_API_KEY")
?? Environment.GetEnvironmentVariable("GOOGLE_API_KEY")
?? string.Empty;
ConfigureHttpClient();
}
private void ConfigureHttpClient()
{
_httpClient.BaseAddress = new Uri(_config.BaseUrl.TrimEnd('/') + "/");
_httpClient.Timeout = _config.Timeout;
}
public async Task<bool> IsAvailableAsync(CancellationToken cancellationToken = default)
{
if (!_config.Enabled)
{
return false;
}
if (string.IsNullOrEmpty(_apiKey))
{
return false;
}
try
{
// Test by listing models
var response = await _httpClient.GetAsync($"models?key={_apiKey}", cancellationToken);
return response.IsSuccessStatusCode;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Gemini availability check failed");
return false;
}
}
public async Task<LlmCompletionResult> CompleteAsync(
LlmCompletionRequest request,
CancellationToken cancellationToken = default)
{
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var model = request.Model ?? _config.Model;
var temperature = request.Temperature > 0 ? request.Temperature : _config.Temperature;
var maxTokens = request.MaxTokens > 0 ? request.MaxTokens : _config.MaxTokens;
var geminiRequest = new GeminiGenerateRequest
{
Contents = BuildContents(request),
GenerationConfig = new GeminiGenerationConfig
{
Temperature = temperature,
MaxOutputTokens = maxTokens,
TopP = _config.TopP,
TopK = _config.TopK,
StopSequences = request.StopSequences?.ToList()
}
};
// Add system instruction if present
if (!string.IsNullOrEmpty(request.SystemPrompt))
{
geminiRequest.SystemInstruction = new GeminiContent
{
Parts = new List<GeminiPart>
{
new GeminiPart { Text = request.SystemPrompt }
}
};
}
if (_config.LogBodies)
{
_logger.LogDebug("Gemini request: {Request}", JsonSerializer.Serialize(geminiRequest));
}
var endpoint = $"models/{model}:generateContent?key={_apiKey}";
var response = await _httpClient.PostAsJsonAsync(endpoint, geminiRequest, cancellationToken);
response.EnsureSuccessStatusCode();
var geminiResponse = await response.Content.ReadFromJsonAsync<GeminiGenerateResponse>(cancellationToken);
stopwatch.Stop();
if (geminiResponse?.Candidates is null || geminiResponse.Candidates.Count == 0)
{
throw new InvalidOperationException("No completion returned from Gemini");
}
var candidate = geminiResponse.Candidates[0];
var content = candidate.Content?.Parts?.FirstOrDefault()?.Text ?? string.Empty;
if (_config.LogUsage && geminiResponse.UsageMetadata is not null)
{
_logger.LogInformation(
"Gemini usage - Model: {Model}, Input: {InputTokens}, Output: {OutputTokens}, Total: {TotalTokens}",
model,
geminiResponse.UsageMetadata.PromptTokenCount,
geminiResponse.UsageMetadata.CandidatesTokenCount,
geminiResponse.UsageMetadata.TotalTokenCount);
}
return new LlmCompletionResult
{
Content = content,
ModelId = geminiResponse.ModelVersion ?? model,
ProviderId = ProviderId,
InputTokens = geminiResponse.UsageMetadata?.PromptTokenCount,
OutputTokens = geminiResponse.UsageMetadata?.CandidatesTokenCount,
TotalTimeMs = stopwatch.ElapsedMilliseconds,
FinishReason = candidate.FinishReason,
Deterministic = temperature == 0,
RequestId = request.RequestId
};
}
public async IAsyncEnumerable<LlmStreamChunk> CompleteStreamAsync(
LlmCompletionRequest request,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var model = request.Model ?? _config.Model;
var temperature = request.Temperature > 0 ? request.Temperature : _config.Temperature;
var maxTokens = request.MaxTokens > 0 ? request.MaxTokens : _config.MaxTokens;
var geminiRequest = new GeminiGenerateRequest
{
Contents = BuildContents(request),
GenerationConfig = new GeminiGenerationConfig
{
Temperature = temperature,
MaxOutputTokens = maxTokens,
TopP = _config.TopP,
TopK = _config.TopK,
StopSequences = request.StopSequences?.ToList()
}
};
// Add system instruction if present
if (!string.IsNullOrEmpty(request.SystemPrompt))
{
geminiRequest.SystemInstruction = new GeminiContent
{
Parts = new List<GeminiPart>
{
new GeminiPart { Text = request.SystemPrompt }
}
};
}
var endpoint = $"models/{model}:streamGenerateContent?key={_apiKey}&alt=sse";
var httpRequest = new HttpRequestMessage(HttpMethod.Post, endpoint)
{
Content = new StringContent(
JsonSerializer.Serialize(geminiRequest),
Encoding.UTF8,
"application/json")
};
var response = await _httpClient.SendAsync(
httpRequest,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var reader = new StreamReader(stream);
string? line;
while ((line = await reader.ReadLineAsync(cancellationToken)) is not null)
{
cancellationToken.ThrowIfCancellationRequested();
if (string.IsNullOrEmpty(line))
{
continue;
}
if (!line.StartsWith("data: "))
{
continue;
}
var data = line.Substring(6);
GeminiStreamResponse? chunk;
try
{
chunk = JsonSerializer.Deserialize<GeminiStreamResponse>(data);
}
catch
{
continue;
}
if (chunk?.Candidates is null || chunk.Candidates.Count == 0)
{
continue;
}
var candidate = chunk.Candidates[0];
var content = candidate.Content?.Parts?.FirstOrDefault()?.Text ?? string.Empty;
var isFinal = !string.IsNullOrEmpty(candidate.FinishReason);
yield return new LlmStreamChunk
{
Content = content,
IsFinal = isFinal,
FinishReason = candidate.FinishReason
};
if (isFinal)
{
yield break;
}
}
// End of stream
yield return new LlmStreamChunk
{
Content = string.Empty,
IsFinal = true,
FinishReason = "STOP"
};
}
private static List<GeminiContent> BuildContents(LlmCompletionRequest request)
{
var contents = new List<GeminiContent>
{
new GeminiContent
{
Role = "user",
Parts = new List<GeminiPart>
{
new GeminiPart { Text = request.UserPrompt }
}
}
};
return contents;
}
public void Dispose()
{
if (!_disposed)
{
_httpClient.Dispose();
_disposed = true;
}
}
}
// Gemini API models
internal sealed class GeminiGenerateRequest
{
[JsonPropertyName("contents")]
public required List<GeminiContent> Contents { get; set; }
[JsonPropertyName("systemInstruction")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public GeminiContent? SystemInstruction { get; set; }
[JsonPropertyName("generationConfig")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public GeminiGenerationConfig? GenerationConfig { get; set; }
}
internal sealed class GeminiContent
{
[JsonPropertyName("role")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Role { get; set; }
[JsonPropertyName("parts")]
public List<GeminiPart>? Parts { get; set; }
}
internal sealed class GeminiPart
{
[JsonPropertyName("text")]
public string? Text { get; set; }
}
internal sealed class GeminiGenerationConfig
{
[JsonPropertyName("temperature")]
public double Temperature { get; set; }
[JsonPropertyName("maxOutputTokens")]
public int MaxOutputTokens { get; set; }
[JsonPropertyName("topP")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public double TopP { get; set; }
[JsonPropertyName("topK")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public int TopK { get; set; }
[JsonPropertyName("stopSequences")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<string>? StopSequences { get; set; }
}
internal sealed class GeminiGenerateResponse
{
[JsonPropertyName("candidates")]
public List<GeminiCandidate>? Candidates { get; set; }
[JsonPropertyName("usageMetadata")]
public GeminiUsageMetadata? UsageMetadata { get; set; }
[JsonPropertyName("modelVersion")]
public string? ModelVersion { get; set; }
}
internal sealed class GeminiCandidate
{
[JsonPropertyName("content")]
public GeminiContent? Content { get; set; }
[JsonPropertyName("finishReason")]
public string? FinishReason { get; set; }
[JsonPropertyName("index")]
public int Index { get; set; }
}
internal sealed class GeminiUsageMetadata
{
[JsonPropertyName("promptTokenCount")]
public int PromptTokenCount { get; set; }
[JsonPropertyName("candidatesTokenCount")]
public int CandidatesTokenCount { get; set; }
[JsonPropertyName("totalTokenCount")]
public int TotalTokenCount { get; set; }
}
internal sealed class GeminiStreamResponse
{
[JsonPropertyName("candidates")]
public List<GeminiCandidate>? Candidates { get; set; }
[JsonPropertyName("usageMetadata")]
public GeminiUsageMetadata? UsageMetadata { get; set; }
}

View File

@@ -132,6 +132,7 @@ public static class LlmProviderPluginExtensions
// Register built-in plugins
catalog.RegisterPlugin(new OpenAiLlmProviderPlugin());
catalog.RegisterPlugin(new ClaudeLlmProviderPlugin());
catalog.RegisterPlugin(new GeminiLlmProviderPlugin());
catalog.RegisterPlugin(new LlamaServerLlmProviderPlugin());
catalog.RegisterPlugin(new OllamaLlmProviderPlugin());
@@ -166,6 +167,7 @@ public static class LlmProviderPluginExtensions
// Register built-in plugins
catalog.RegisterPlugin(new OpenAiLlmProviderPlugin());
catalog.RegisterPlugin(new ClaudeLlmProviderPlugin());
catalog.RegisterPlugin(new GeminiLlmProviderPlugin());
catalog.RegisterPlugin(new LlamaServerLlmProviderPlugin());
catalog.RegisterPlugin(new OllamaLlmProviderPlugin());