diff --git a/.env.example b/.env.example index 0ab8727..175a453 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,8 @@ POSTGRES_PASSWORD=postgres NEMOTRON_API_KEY= VISION_API_KEY= +ANTHROPIC_API_KEY= +ANTHROPIC_WORKSPACE_ID= BLS_API_KEY= EBAY_CLIENT_ID= EBAY_CLIENT_SECRET= diff --git a/Ai/ClaudeLlmService.cs b/Ai/ClaudeLlmService.cs new file mode 100644 index 0000000..31dd526 --- /dev/null +++ b/Ai/ClaudeLlmService.cs @@ -0,0 +1,147 @@ +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Options; +using MoneyMirror.Ai.Configuration; + +namespace MoneyMirror.Ai; + +/// +/// implementation calling Anthropic Claude's Messages API. +/// Used only as 's backup - never registered as +/// on its own. +/// +public class ClaudeLlmService : ILlmService +{ + private const string ApiKeyHeader = "x-api-key"; + private const string VersionHeader = "anthropic-version"; + private const string WorkspaceIdHeader = "anthropic-workspace-id"; + + private readonly HttpClient _httpClient; + private readonly ClaudeOptions _options; + + public ClaudeLlmService(HttpClient httpClient, IOptions options) + { + _httpClient = httpClient; + _options = options.Value; + } + + public async Task CompleteAsync( + string prompt, + CancellationToken cancellationToken = default + ) + { + var requestBody = new MessagesRequest + { + Model = _options.Model, + Messages = [new ClaudeMessage { Role = "user", Content = prompt }], + // Mirrors NemotronLlmService's cap: every prompt this service is used for + // expects a short answer or a compact JSON object. + MaxTokens = 4096, + }; + + HttpResponseMessage response; + try + { + using var httpRequest = new HttpRequestMessage( + HttpMethod.Post, + $"{_options.BaseUrl.TrimEnd('/')}/messages" + ) + { + Content = JsonContent.Create(requestBody), + }; + httpRequest.Headers.Add(ApiKeyHeader, _options.ApiKey); + httpRequest.Headers.Add(VersionHeader, _options.AnthropicVersion); + if (!string.IsNullOrEmpty(_options.WorkspaceId)) + { + httpRequest.Headers.Add(WorkspaceIdHeader, _options.WorkspaceId); + } + + response = await _httpClient.SendAsync(httpRequest, cancellationToken); + } + catch (HttpRequestException ex) + { + throw new LlmServiceException("Failed to reach the Claude LLM provider.", ex); + } + catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) + { + throw new LlmServiceException("Claude LLM request timed out.", ex); + } + + using (response) + { + if (!response.IsSuccessStatusCode) + { + var errorBody = await response.Content.ReadAsStringAsync(cancellationToken); + throw new LlmServiceException( + $"Claude LLM provider returned {(int)response.StatusCode} {response.StatusCode}: {errorBody}" + ) + { + RawResponse = errorBody, + }; + } + + MessagesResponse? completion; + try + { + completion = await response.Content.ReadFromJsonAsync( + cancellationToken + ); + } + catch (Exception ex) + { + throw new LlmServiceException("Failed to parse the Claude LLM response.", ex); + } + + var content = completion + ?.Content?.FirstOrDefault(block => block.Type == "text") + ?.Text; + if (string.IsNullOrEmpty(content)) + { + throw new LlmServiceException( + "Claude LLM response contained no completion content." + ) + { + RawResponse = content, + }; + } + + return content; + } + } + + private class MessagesRequest + { + [JsonPropertyName("model")] + public required string Model { get; init; } + + [JsonPropertyName("messages")] + public required ClaudeMessage[] Messages { get; init; } + + [JsonPropertyName("max_tokens")] + public required int MaxTokens { get; init; } + } + + private class ClaudeMessage + { + [JsonPropertyName("role")] + public required string Role { get; init; } + + [JsonPropertyName("content")] + public required string Content { get; init; } + } + + private class MessagesResponse + { + [JsonPropertyName("content")] + public ContentBlock[]? Content { get; init; } + } + + private class ContentBlock + { + [JsonPropertyName("type")] + public string? Type { get; init; } + + [JsonPropertyName("text")] + public string? Text { get; init; } + } +} diff --git a/Ai/Configuration/ClaudeOptions.cs b/Ai/Configuration/ClaudeOptions.cs new file mode 100644 index 0000000..0e257a4 --- /dev/null +++ b/Ai/Configuration/ClaudeOptions.cs @@ -0,0 +1,23 @@ +namespace MoneyMirror.Ai.Configuration; + +/// +/// Configuration for the Anthropic Claude LLM provider, bound from the "Ai:Claude" +/// section. Used only as 's backup - +/// see that type's remarks for why a second provider exists at all. +/// +public class ClaudeOptions +{ + public const string SectionName = "Ai:Claude"; + + public string ApiKey { get; set; } = string.Empty; + public string BaseUrl { get; set; } = "https://api.anthropic.com/v1"; + public string Model { get; set; } = "claude-sonnet-4-5"; + public string AnthropicVersion { get; set; } = "2023-06-01"; + + /// + /// Required only for API keys that are not scoped to a single workspace; + /// Anthropic rejects those requests without this header. Left empty when the + /// key is already workspace-scoped. + /// + public string WorkspaceId { get; set; } = string.Empty; +} diff --git a/Ai/FallbackLlmService.cs b/Ai/FallbackLlmService.cs new file mode 100644 index 0000000..7be8fd9 --- /dev/null +++ b/Ai/FallbackLlmService.cs @@ -0,0 +1,39 @@ +namespace MoneyMirror.Ai; + +/// +/// implementation that tries +/// first and falls back to whenever Nemotron is +/// unreachable, errors out, or returns something unusable. +/// +/// Nemotron stays the primary provider - Claude only covers the gap during an NVIDIA +/// outage or a saturated queue could not +/// clear. A prompt is never sent to both; the fallback only fires after the primary +/// has already failed. +/// +/// +public class FallbackLlmService : ILlmService +{ + private readonly NemotronLlmService _primary; + private readonly ClaudeLlmService _fallback; + + public FallbackLlmService(NemotronLlmService primary, ClaudeLlmService fallback) + { + _primary = primary; + _fallback = fallback; + } + + public async Task CompleteAsync( + string prompt, + CancellationToken cancellationToken = default + ) + { + try + { + return await _primary.CompleteAsync(prompt, cancellationToken); + } + catch (LlmServiceException) + { + return await _fallback.CompleteAsync(prompt, cancellationToken); + } + } +} diff --git a/Ai/ILlmService.cs b/Ai/ILlmService.cs index c66ac56..59670d7 100644 --- a/Ai/ILlmService.cs +++ b/Ai/ILlmService.cs @@ -1,8 +1,10 @@ namespace MoneyMirror.Ai; /// -/// Thin seam over the LLM provider (NVIDIA Nemotron): a prompt in, a completion out. -/// Feature code should depend on this interface, never on the concrete provider. +/// Thin seam over the LLM provider: a prompt in, a completion out. Feature code +/// should depend on this interface, never on a concrete provider. The registered +/// implementation is , which tries NVIDIA Nemotron +/// first and falls back to Anthropic Claude. /// public interface ILlmService { diff --git a/Program.cs b/Program.cs index 17a6527..c74351c 100644 --- a/Program.cs +++ b/Program.cs @@ -29,6 +29,9 @@ builder.Services.Configure( builder.Configuration.GetSection(NemotronOptions.SectionName) ); +builder.Services.Configure( + builder.Configuration.GetSection(ClaudeOptions.SectionName) +); builder.Services.Configure(builder.Configuration.GetSection(BlsOptions.SectionName)); builder.Services.Configure( builder.Configuration.GetSection(EbayOptions.SectionName) @@ -55,21 +58,31 @@ return new MarketDataSearchCache(cacheDirectory); }); -// Both AI clients share NVIDIA's endpoint, which sheds load with a 503 when its -// workers are saturated - see TransientFaultRetryHandler. HttpClient.Timeout +// The vision client shares NVIDIA's endpoint with Nemotron, which sheds load with a +// 503 when its workers are saturated - see TransientFaultRetryHandler. HttpClient.Timeout // wraps the whole SendAsync pipeline, including TransientFaultRetryHandler's // retries, so this is a hard ceiling on total time spent per call, not just // the first attempt. #261: previously unset (100s .NET default), so a stuck // request had no clear bound and no chance to surface the app's own -// "could not be loaded" error UI. 90s for the LLM because Nemotron is a -// reasoning model that has been observed taking upwards of 60s for a +// "could not be loaded" error UI. 90s for the LLM because Nemotron and Claude are +// both reasoning models that have been observed taking upwards of 60s for a // legitimate (non-error) response; the vision model and the plain BLS REST // API are comparatively fast, so they get tighter budgets. +// +// Registered as themselves, not as ILlmService, so FallbackLlmService can depend on +// both concretely and be the sole ILlmService registration - see its remarks for why +// Claude exists at all. +builder + .Services.AddHttpClient(client => + client.Timeout = TimeSpan.FromSeconds(90) + ) + .AddHttpMessageHandler(); builder - .Services.AddHttpClient(client => + .Services.AddHttpClient(client => client.Timeout = TimeSpan.FromSeconds(90) ) .AddHttpMessageHandler(); +builder.Services.AddScoped(); builder .Services.AddHttpClient(client => client.Timeout = TimeSpan.FromSeconds(60) diff --git a/README.md b/README.md index ecaf325..e40251c 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,11 @@ under `Ai:Nemotron` and `Ai:VisionModel`. Both hosted models point at the same endpoint, so one NVIDIA API key covers them. +Every Nemotron job runs through `FallbackLlmService`, which retries the same +prompt against Anthropic Claude (`claude-sonnet-4-5`) whenever Nemotron is +unreachable or errors out. Claude is a backup, not a fourth job - it never +runs unless Nemotron already failed. + MobileSAM never leaves the client. You tap an object in the camera view and the mask is computed on device, which keeps the interaction instant and means only the crop you chose is ever uploaded. diff --git a/appsettings.json b/appsettings.json index 2868530..63e08e2 100644 --- a/appsettings.json +++ b/appsettings.json @@ -16,6 +16,13 @@ "ApiKey": "", "BaseUrl": "https://integrate.api.nvidia.com/v1", "Model": "meta/llama-3.2-11b-vision-instruct" + }, + "Claude": { + "ApiKey": "", + "WorkspaceId": "", + "BaseUrl": "https://api.anthropic.com/v1", + "Model": "claude-sonnet-4-5", + "AnthropicVersion": "2023-06-01" } }, "ConnectionStrings": { diff --git a/docker-compose.yml b/docker-compose.yml index 2cf4da3..5de1415 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,8 @@ services: ConnectionStrings__DefaultConnection: Host=postgres;Port=5432;Database=${POSTGRES_DB:-MoneyMirror};Username=${POSTGRES_USER:-postgres};Password=${POSTGRES_PASSWORD:-postgres} Ai__Nemotron__ApiKey: ${NEMOTRON_API_KEY:-} Ai__VisionModel__ApiKey: ${VISION_API_KEY:-} + Ai__Claude__ApiKey: ${ANTHROPIC_API_KEY:-} + Ai__Claude__WorkspaceId: ${ANTHROPIC_WORKSPACE_ID:-} Bls__ApiKey: ${BLS_API_KEY:-} Ebay__ClientId: ${EBAY_CLIENT_ID:-} Ebay__ClientSecret: ${EBAY_CLIENT_SECRET:-} diff --git a/docs/development.md b/docs/development.md index 2333c63..d12dcc0 100644 --- a/docs/development.md +++ b/docs/development.md @@ -7,20 +7,27 @@ monolith, one database, one implicit user — no auth or multi-tenancy. ## Configuration AI, BLS, and eBay Browse API settings live under `Ai:Nemotron`, -`Ai:VisionModel`, `Bls`, and `Ebay` (`ClientId`, `ClientSecret`, `AuthUrl`, -`SearchUrl`, `MarketplaceId`, and `CacheDurationHours`). `appsettings.json` -ships empty secret placeholders — never commit real keys. Set them locally: +`Ai:VisionModel`, `Ai:Claude`, `Bls`, and `Ebay` (`ClientId`, `ClientSecret`, +`AuthUrl`, `SearchUrl`, `MarketplaceId`, and `CacheDurationHours`). +`appsettings.json` ships empty secret placeholders — never commit real keys. +`Ai:Claude` is a fallback only: `FallbackLlmService` uses it when Nemotron is +unreachable or errors out, so it's optional in dev but required for prod +resilience. `Ai:Claude:WorkspaceId` is only needed for API keys that are not +already scoped to a single workspace. Set them locally: ```sh dotnet user-secrets set "Ai:Nemotron:ApiKey" "" dotnet user-secrets set "Ai:VisionModel:ApiKey" "" +dotnet user-secrets set "Ai:Claude:ApiKey" "" +dotnet user-secrets set "Ai:Claude:WorkspaceId" "" dotnet user-secrets set "Bls:ApiKey" "" dotnet user-secrets set "Ebay:ClientId" "" dotnet user-secrets set "Ebay:ClientSecret" "" ``` Or use env vars: `Ai__Nemotron__ApiKey`, `Ai__VisionModel__ApiKey`, -`Bls__ApiKey`, `Ebay__ClientId`, `Ebay__ClientSecret`. +`Ai__Claude__ApiKey`, `Ai__Claude__WorkspaceId`, `Bls__ApiKey`, +`Ebay__ClientId`, `Ebay__ClientSecret`. Physical asset valuations authenticate to eBay's Browse API with an OAuth2 client-credentials app token (cached in memory for its ~2-hour @@ -60,6 +67,8 @@ on Postgres volumes and host-local `dotnet run`: [backend/postgresql_setup.md](. | `POSTGRES_*` | `ConnectionStrings:DefaultConnection` | | `NEMOTRON_API_KEY` | `Ai:Nemotron:ApiKey` | | `VISION_API_KEY` | `Ai:VisionModel:ApiKey` | +| `ANTHROPIC_API_KEY` | `Ai:Claude:ApiKey` | +| `ANTHROPIC_WORKSPACE_ID` | `Ai:Claude:WorkspaceId` | | `BLS_API_KEY` | `Bls:ApiKey` | | `EBAY_CLIENT_ID` | `Ebay:ClientId` | | `EBAY_CLIENT_SECRET` | `Ebay:ClientSecret` |