audit notes work completed, test fixes work (95% done), new sprints, new data sources setup and configuration
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user