feat(ai): DB-only config + runtime provider selection via factory

This commit is contained in:
Chris Chen
2026-06-25 13:23:13 -07:00
parent ece9938bfb
commit 120240ad0c
8 changed files with 132 additions and 57 deletions
@@ -1,6 +1,5 @@
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Options;
using ROLAC.API.Data;
namespace ROLAC.API.Services.Ai;
@@ -11,29 +10,33 @@ namespace ROLAC.API.Services.Ai;
/// answer shape, so the model returns structured JSON in a <c>tool_use</c> block. The catalog,
/// prompt, and id validation come from <see cref="ExpenseAiServiceBase"/>; this class only owns the
/// Claude HTTP call + parse. Forced tool use works on every Claude model, so the configured
/// <see cref="ClaudeOptions.Model"/> can be swapped (e.g. to a cheaper model) without code changes.
/// model can be swapped (e.g. to a cheaper model) without code changes.
/// </summary>
public sealed class ClaudeExpenseAiService : ExpenseAiServiceBase
{
private const string BaseUrl = "https://api.anthropic.com/v1";
private const string AnthropicVersion = "2023-06-01";
private readonly HttpClient _http;
private readonly ClaudeOptions _options;
private readonly IChurchAiConfigProvider _config;
private readonly ILogger<ClaudeExpenseAiService> _logger;
public ClaudeExpenseAiService(
HttpClient http,
IOptions<ClaudeOptions> options,
IChurchAiConfigProvider config,
AppDbContext db,
ILogger<ClaudeExpenseAiService> logger)
: base(db)
{
_http = http;
_options = options.Value;
_config = config;
_logger = logger;
}
protected override async Task<ModelAnswer?> CallModelAsync(string prompt, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(_options.ApiKey))
var cfg = await _config.GetAsync(ct);
if (string.IsNullOrWhiteSpace(cfg.ClaudeApiKey))
{
_logger.LogWarning("Claude API key is not configured; expense AI assist is disabled.");
return null;
@@ -43,7 +46,7 @@ public sealed class ClaudeExpenseAiService : ExpenseAiServiceBase
{
var payload = new
{
model = _options.Model,
model = cfg.ClaudeModel,
max_tokens = 1024,
tools = new[]
{
@@ -73,13 +76,13 @@ public sealed class ClaudeExpenseAiService : ExpenseAiServiceBase
},
};
var url = $"{_options.BaseUrl}/messages";
var url = $"{BaseUrl}/messages";
using var request = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = JsonContent.Create(payload),
};
request.Headers.Add("x-api-key", _options.ApiKey);
request.Headers.Add("anthropic-version", _options.AnthropicVersion);
request.Headers.Add("x-api-key", cfg.ClaudeApiKey);
request.Headers.Add("anthropic-version", AnthropicVersion);
using var response = await _http.SendAsync(request, ct);
if (!response.IsSuccessStatusCode)