Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
147 changes: 147 additions & 0 deletions Ai/ClaudeLlmService.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// <see cref="ILlmService"/> implementation calling Anthropic Claude's Messages API.
/// Used only as <see cref="FallbackLlmService"/>'s backup - never registered as
/// <see cref="ILlmService"/> on its own.
/// </summary>
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<ClaudeOptions> options)
{
_httpClient = httpClient;
_options = options.Value;
}

public async Task<string> 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<MessagesResponse>(
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; }
}
}
23 changes: 23 additions & 0 deletions Ai/Configuration/ClaudeOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
namespace MoneyMirror.Ai.Configuration;

/// <summary>
/// Configuration for the Anthropic Claude LLM provider, bound from the "Ai:Claude"
/// section. Used only as <see cref="MoneyMirror.Ai.FallbackLlmService"/>'s backup -
/// see that type's remarks for why a second provider exists at all.
/// </summary>
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";

/// <summary>
/// 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.
/// </summary>
public string WorkspaceId { get; set; } = string.Empty;
}
39 changes: 39 additions & 0 deletions Ai/FallbackLlmService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
namespace MoneyMirror.Ai;

/// <summary>
/// <see cref="ILlmService"/> implementation that tries <see cref="NemotronLlmService"/>
/// first and falls back to <see cref="ClaudeLlmService"/> whenever Nemotron is
/// unreachable, errors out, or returns something unusable.
/// <para>
/// Nemotron stays the primary provider - Claude only covers the gap during an NVIDIA
/// outage or a saturated queue <see cref="TransientFaultRetryHandler"/> could not
/// clear. A prompt is never sent to both; the fallback only fires after the primary
/// has already failed.
/// </para>
/// </summary>
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<string> CompleteAsync(
string prompt,
CancellationToken cancellationToken = default
)
{
try
{
return await _primary.CompleteAsync(prompt, cancellationToken);
}
catch (LlmServiceException)
{
return await _fallback.CompleteAsync(prompt, cancellationToken);
}
}
}
6 changes: 4 additions & 2 deletions Ai/ILlmService.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
namespace MoneyMirror.Ai;

/// <summary>
/// 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 <see cref="FallbackLlmService"/>, which tries NVIDIA Nemotron
/// first and falls back to Anthropic Claude.
/// </summary>
public interface ILlmService
{
Expand Down
23 changes: 18 additions & 5 deletions Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@
builder.Services.Configure<NemotronOptions>(
builder.Configuration.GetSection(NemotronOptions.SectionName)
);
builder.Services.Configure<ClaudeOptions>(
builder.Configuration.GetSection(ClaudeOptions.SectionName)
);
builder.Services.Configure<BlsOptions>(builder.Configuration.GetSection(BlsOptions.SectionName));
builder.Services.Configure<EbayOptions>(
builder.Configuration.GetSection(EbayOptions.SectionName)
Expand All @@ -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<NemotronLlmService>(client =>
client.Timeout = TimeSpan.FromSeconds(90)
)
.AddHttpMessageHandler<TransientFaultRetryHandler>();
builder
.Services.AddHttpClient<ILlmService, NemotronLlmService>(client =>
.Services.AddHttpClient<ClaudeLlmService>(client =>
client.Timeout = TimeSpan.FromSeconds(90)
)
.AddHttpMessageHandler<TransientFaultRetryHandler>();
builder.Services.AddScoped<ILlmService, FallbackLlmService>();
builder
.Services.AddHttpClient<IVisionService, NvidiaVisionService>(client =>
client.Timeout = TimeSpan.FromSeconds(60)
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:-}
Expand Down
17 changes: 13 additions & 4 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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" "<your-key>"
dotnet user-secrets set "Ai:VisionModel:ApiKey" "<your-key>"
dotnet user-secrets set "Ai:Claude:ApiKey" "<your-key>"
dotnet user-secrets set "Ai:Claude:WorkspaceId" "<your-workspace-id>"
dotnet user-secrets set "Bls:ApiKey" "<your-key>"
dotnet user-secrets set "Ebay:ClientId" "<your-ebay-client-id>"
dotnet user-secrets set "Ebay:ClientSecret" "<your-ebay-client-secret>"
```

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
Expand Down Expand Up @@ -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` |
Expand Down
Loading