diff --git a/.env.example b/.env.example index 2ec083f..7986e49 100644 --- a/.env.example +++ b/.env.example @@ -13,15 +13,23 @@ MONARCH_API_KEY=your-monarch-api-key # Leave empty to disable Sentry SENTRY_DSN=https://275ad5c45f7eb6454f31ae6a8c325f46@o4509941888122880.ingest.us.sentry.io/4509942073131008 -# LLM Options (Phase 2 - choose one) -# Option 1: Ollama (FREE, local) -OLLAMA_ENDPOINT=http://localhost:11434 +# LLM categorizer — set one of the following keys. +# Auto-detected from whichever key is set. To force a choice when both are +# present, set CATEGORIZER_PROVIDER=openai or CATEGORIZER_PROVIDER=anthropic. -# Option 2: OpenAI (paid) +# OpenAI (default if no provider is forced) # OPENAI_API_KEY=your-openai-api-key +# OPENAI_MODEL=gpt-5.4-nano + +# Anthropic (Claude) +# ANTHROPIC_API_KEY=your-anthropic-api-key # CLAUDE_API_KEY also accepted +# ANTHROPIC_MODEL=claude-haiku-4-5-20251001 -# Option 3: Claude API (paid) -# CLAUDE_API_KEY=your-claude-api-key +# Explicit backend selection (optional) +# CATEGORIZER_PROVIDER=anthropic + +# Ollama (planned, not yet wired) +OLLAMA_ENDPOINT=http://localhost:11434 # Database (Future) # DATABASE_URL=postgresql://user:password@localhost/walmart_monarch_sync diff --git a/AGENTS.md b/AGENTS.md index 733ba12..229e78d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,8 +37,10 @@ go test ./internal/domain/categorizer/... -v # single package Reads `config.yaml` or env vars: - `MONARCH_TOKEN` — Monarch API token (required) -- `OPENAI_APIKEY` / `OPENAI_API_KEY` — OpenAI key (required) -- `OPENAI_MODEL` — categorization model (default in `config.go`; override per-run for A/B testing) +- LLM categorizer — set **one** of: + - `OPENAI_API_KEY` (also `OPENAI_APIKEY`) with optional `OPENAI_MODEL` (default `gpt-5.4-nano`) + - `ANTHROPIC_API_KEY` (also `CLAUDE_API_KEY`) with optional `ANTHROPIC_MODEL` (default `claude-haiku-4-5-20251001`) +- `CATEGORIZER_PROVIDER` — `openai` or `anthropic` to force a backend when both keys are set (auto-detected otherwise) - SQLite DB auto-created at `monarch_sync.db` ## Architecture @@ -48,12 +50,16 @@ Layered, with the domain core kept dependency-free. Flow: **CLI → application ``` internal/ application/sync/ orchestrator + per-provider handlers (walmart, costco, amazon, simple) - domain/ pure logic: categorizer/ (OpenAI), matcher/ (fuzzy), splitter/ (splits + tax) - adapters/ providers/ (walmart, costco, amazon) and clients/ (Monarch, OpenAI builders) + domain/ pure logic: categorizer/ (pluggable LLM backend), matcher/ (fuzzy), splitter/ (splits + tax) + adapters/ providers/ (walmart, costco, amazon) and clients/ (Monarch, openai/, anthropic/) infrastructure/ config/, storage/ (SQLite + goose migrations), logging/ (slog) cli/ flag parsing + output ``` +The categorizer depends on a `ChatClient` interface; concrete LLM backends +live under `internal/adapters/clients/{openai,anthropic}`. Selection happens +in `internal/adapters/clients/clients.go:newChatClient`. + Key entry points: - Orchestrator — `internal/application/sync/orchestrator.go` - Matcher (amount ±$0.01, date ±5 days) — `internal/domain/matcher/matcher.go` diff --git a/README.md b/README.md index c240909..4d384aa 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ CLI tool that syncs purchases from Walmart, Costco, and Amazon with Monarch Mone 1. Fetches orders from retailers with item details 2. Matches them to transactions in Monarch Money -3. Categorizes items using OpenAI +3. Categorizes items using an LLM (OpenAI or Anthropic Claude) 4. Splits the transaction by category with proportional tax **Example**: A $150 Walmart transaction becomes: @@ -20,7 +20,7 @@ CLI tool that syncs purchases from Walmart, Costco, and Amazon with Monarch Mone - Go 1.24+ - Monarch Money account -- OpenAI API key +- An LLM API key — OpenAI or Anthropic (Claude) - Retailer account(s) ### Install @@ -36,7 +36,11 @@ go build -o itemize ./cmd/itemize/ Set environment variables: ```bash export MONARCH_TOKEN="your_monarch_token" + +# Pick one LLM backend: export OPENAI_API_KEY="your_openai_key" +# or +export ANTHROPIC_API_KEY="your_anthropic_key" ``` Or create `config.yaml`: @@ -48,10 +52,29 @@ openai: api_key: "${OPENAI_API_KEY}" model: "gpt-5.4-nano" +anthropic: + api_key: "${ANTHROPIC_API_KEY}" + model: "claude-haiku-4-5-20251001" + +# Optional: force a backend when both keys are set. +# Leave blank to auto-detect from whichever key is present. +categorizer: + provider: "" # "openai" | "anthropic" | "" + storage: database_path: "monarch_sync.db" ``` +#### Choosing an LLM backend + +itemize picks the LLM backend based on which API key is configured: + +- Only `OPENAI_API_KEY` set → OpenAI is used. +- Only `ANTHROPIC_API_KEY` (or `CLAUDE_API_KEY`) set → Claude is used. +- Both set → defaults to OpenAI; set `CATEGORIZER_PROVIDER=anthropic` to force Claude. + +Override the model with `OPENAI_MODEL` or `ANTHROPIC_MODEL` per run. + ## Usage ```bash diff --git a/config.yaml b/config.yaml index b3fdab5..33be590 100644 --- a/config.yaml +++ b/config.yaml @@ -37,6 +37,18 @@ openai: api_key: "${OPENAI_API_KEY}" model: "gpt-5.4-nano" +# Anthropic (Claude) configuration — used when CATEGORIZER_PROVIDER=anthropic +# or when ANTHROPIC_API_KEY is the only LLM key set. +anthropic: + api_key: "${ANTHROPIC_API_KEY}" + model: "claude-haiku-4-5-20251001" + +# Categorizer backend selection. +# Leave provider empty to auto-detect from which API key is set. +# Set to "openai" or "anthropic" to force a specific backend. +categorizer: + provider: "${CATEGORIZER_PROVIDER}" + # Storage configuration storage: database_path: "monarch_sync.db" # Consolidated database diff --git a/internal/adapters/clients/anthropic/client.go b/internal/adapters/clients/anthropic/client.go new file mode 100644 index 0000000..7bb3f43 --- /dev/null +++ b/internal/adapters/clients/anthropic/client.go @@ -0,0 +1,180 @@ +// Package anthropic contains the Anthropic (Claude) HTTP adapter +// implementing categorizer.ChatClient. +// +// It translates the OpenAI-shaped chat-completion request the categorizer +// uses into Anthropic's Messages API request, and translates the response +// back. When the caller asks for JSON (ResponseFormat.Type == "json_object"), +// the adapter prefills the assistant turn with "{" so the model emits a +// JSON object suitable for the categorizer's existing json.Unmarshal step. +package anthropic + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/eshaffer321/monarchmoney-sync-backend/internal/domain/categorizer" +) + +const ( + defaultBaseURL = "https://api.anthropic.com/v1" + defaultAPIVersion = "2023-06-01" + defaultMaxTokens = 4096 + defaultClientTimeout = 30 * time.Second +) + +// Client is the HTTP-backed Anthropic implementation of categorizer.ChatClient. +type Client struct { + apiKey string + httpClient *http.Client + baseURL string + apiVersion string +} + +// NewClient creates a new Anthropic client. +func NewClient(apiKey string) *Client { + return &Client{ + apiKey: apiKey, + baseURL: defaultBaseURL, + apiVersion: defaultAPIVersion, + httpClient: &http.Client{Timeout: defaultClientTimeout}, + } +} + +// CreateChatCompletion translates the request to Anthropic's Messages API, +// posts it, and returns the response in the categorizer's vocabulary. +func (c *Client) CreateChatCompletion(ctx context.Context, request categorizer.ChatCompletionRequest) (*categorizer.ChatCompletionResponse, error) { + msgReq, prefilled := buildMessagesRequest(request) + + requestBody, err := json.Marshal(msgReq) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/messages", bytes.NewBuffer(requestBody)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-api-key", c.apiKey) + req.Header.Set("anthropic-version", c.apiVersion) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to make request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + var errorResp struct { + Error struct { + Type string `json:"type"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(body, &errorResp); err == nil && errorResp.Error.Message != "" { + return nil, fmt.Errorf("anthropic API error: %s (type: %s)", + errorResp.Error.Message, errorResp.Error.Type) + } + return nil, fmt.Errorf("anthropic API returned status %d: %s", resp.StatusCode, string(body)) + } + + return parseMessagesResponse(body, prefilled) +} + +// messagesRequest is Anthropic's Messages API request body. +type messagesRequest struct { + Model string `json:"model"` + MaxTokens int `json:"max_tokens"` + System string `json:"system,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + Messages []messagesTurn `json:"messages"` +} + +type messagesTurn struct { + Role string `json:"role"` + Content string `json:"content"` +} + +// messagesResponse is the relevant subset of Anthropic's Messages API response. +type messagesResponse struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` +} + +// buildMessagesRequest translates a ChatCompletionRequest into Anthropic's +// Messages API shape. Returns the request plus a bool indicating whether the +// assistant turn was prefilled with "{" — needed to reconstruct the JSON on +// the response side. +func buildMessagesRequest(req categorizer.ChatCompletionRequest) (messagesRequest, bool) { + out := messagesRequest{ + Model: req.Model, + MaxTokens: defaultMaxTokens, + Temperature: req.Temperature, + } + + for _, m := range req.Messages { + if m.Role == "system" { + // Anthropic takes system as a top-level field; concatenate + // if multiple system messages are passed. + if out.System == "" { + out.System = m.Content + } else { + out.System += "\n\n" + m.Content + } + continue + } + out.Messages = append(out.Messages, messagesTurn{Role: m.Role, Content: m.Content}) + } + + prefilled := false + if req.ResponseFormat != nil && req.ResponseFormat.Type == "json_object" { + out.Messages = append(out.Messages, messagesTurn{Role: "assistant", Content: "{"}) + prefilled = true + } + + return out, prefilled +} + +// parseMessagesResponse extracts the assistant text from Anthropic's response +// and wraps it in the categorizer's ChatCompletionResponse vocabulary. If the +// assistant turn was prefilled with "{", that character is prepended so the +// caller can json.Unmarshal directly. +func parseMessagesResponse(body []byte, prefilled bool) (*categorizer.ChatCompletionResponse, error) { + var resp messagesResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + var text string + for _, block := range resp.Content { + if block.Type == "text" { + text = block.Text + break + } + } + if text == "" { + return nil, fmt.Errorf("anthropic response contained no text content") + } + + if prefilled { + text = "{" + text + } + + return &categorizer.ChatCompletionResponse{ + Choices: []categorizer.Choice{ + {Message: categorizer.Message{Role: "assistant", Content: text}}, + }, + }, nil +} diff --git a/internal/adapters/clients/anthropic/client_test.go b/internal/adapters/clients/anthropic/client_test.go new file mode 100644 index 0000000..3f8fb9d --- /dev/null +++ b/internal/adapters/clients/anthropic/client_test.go @@ -0,0 +1,196 @@ +package anthropic + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/eshaffer321/monarchmoney-sync-backend/internal/domain/categorizer" +) + +func float64Ptr(v float64) *float64 { return &v } + +// newTestClient returns a Client wired to the given test server. +func newTestClient(srv *httptest.Server) *Client { + c := NewClient("test-key") + c.baseURL = srv.URL + return c +} + +func TestBuildMessagesRequest_MapsSystemAndUserMessages(t *testing.T) { + req := categorizer.ChatCompletionRequest{ + Model: "claude-haiku-4-5-20251001", + Temperature: float64Ptr(0.1), + Messages: []categorizer.Message{ + {Role: "system", Content: "you are a sorter"}, + {Role: "user", Content: "sort these"}, + }, + } + + got, prefilled := buildMessagesRequest(req) + + assert.False(t, prefilled) + assert.Equal(t, "claude-haiku-4-5-20251001", got.Model) + assert.Equal(t, defaultMaxTokens, got.MaxTokens) + assert.Equal(t, "you are a sorter", got.System) + require.NotNil(t, got.Temperature) + assert.InDelta(t, 0.1, *got.Temperature, 1e-9) + require.Len(t, got.Messages, 1) + assert.Equal(t, "user", got.Messages[0].Role) + assert.Equal(t, "sort these", got.Messages[0].Content) +} + +func TestBuildMessagesRequest_PrefillsWhenJSONRequested(t *testing.T) { + req := categorizer.ChatCompletionRequest{ + Model: "claude-haiku-4-5-20251001", + Messages: []categorizer.Message{ + {Role: "user", Content: "give me json"}, + }, + ResponseFormat: &categorizer.ResponseFormat{Type: "json_object"}, + } + + got, prefilled := buildMessagesRequest(req) + + assert.True(t, prefilled) + require.Len(t, got.Messages, 2) + assert.Equal(t, "assistant", got.Messages[1].Role) + assert.Equal(t, "{", got.Messages[1].Content) +} + +func TestBuildMessagesRequest_ConcatenatesMultipleSystemMessages(t *testing.T) { + req := categorizer.ChatCompletionRequest{ + Model: "claude-haiku-4-5-20251001", + Messages: []categorizer.Message{ + {Role: "system", Content: "first"}, + {Role: "system", Content: "second"}, + {Role: "user", Content: "go"}, + }, + } + + got, _ := buildMessagesRequest(req) + + assert.Equal(t, "first\n\nsecond", got.System) +} + +func TestCreateChatCompletion_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/messages", r.URL.Path) + assert.Equal(t, "test-key", r.Header.Get("x-api-key")) + assert.Equal(t, defaultAPIVersion, r.Header.Get("anthropic-version")) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + + var body messagesRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "claude-haiku-4-5-20251001", body.Model) + assert.Equal(t, defaultMaxTokens, body.MaxTokens) + + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"content":[{"type":"text","text":"hello back"}]}`) + })) + defer srv.Close() + + client := newTestClient(srv) + resp, err := client.CreateChatCompletion(context.Background(), categorizer.ChatCompletionRequest{ + Model: "claude-haiku-4-5-20251001", + Messages: []categorizer.Message{{Role: "user", Content: "hi"}}, + }) + + require.NoError(t, err) + require.NotNil(t, resp) + require.Len(t, resp.Choices, 1) + assert.Equal(t, "hello back", resp.Choices[0].Message.Content) +} + +func TestCreateChatCompletion_PrefillsAndReassemblesJSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body messagesRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + // Last message must be the "{" prefill on the assistant turn. + require.Len(t, body.Messages, 2) + assert.Equal(t, "assistant", body.Messages[1].Role) + assert.Equal(t, "{", body.Messages[1].Content) + + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"content":[{"type":"text","text":"\"ok\":true}"}]}`) + })) + defer srv.Close() + + client := newTestClient(srv) + resp, err := client.CreateChatCompletion(context.Background(), categorizer.ChatCompletionRequest{ + Model: "claude-haiku-4-5-20251001", + Messages: []categorizer.Message{{Role: "user", Content: "json please"}}, + ResponseFormat: &categorizer.ResponseFormat{Type: "json_object"}, + }) + + require.NoError(t, err) + require.NotNil(t, resp) + require.Len(t, resp.Choices, 1) + // Prefill reassembled — full message must be valid JSON. + assert.Equal(t, `{"ok":true}`, resp.Choices[0].Message.Content) + var parsed map[string]any + require.NoError(t, json.Unmarshal([]byte(resp.Choices[0].Message.Content), &parsed)) + assert.Equal(t, true, parsed["ok"]) +} + +func TestCreateChatCompletion_StructuredErrorResponse(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = io.WriteString(w, `{"type":"error","error":{"type":"authentication_error","message":"invalid x-api-key"}}`) + })) + defer srv.Close() + + client := newTestClient(srv) + _, err := client.CreateChatCompletion(context.Background(), categorizer.ChatCompletionRequest{ + Model: "claude-haiku-4-5-20251001", + Messages: []categorizer.Message{{Role: "user", Content: "hi"}}, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "anthropic API error") + assert.Contains(t, err.Error(), "invalid x-api-key") + assert.Contains(t, err.Error(), "authentication_error") +} + +func TestCreateChatCompletion_OpaqueServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadGateway) + _, _ = io.WriteString(w, "upstream borked") + })) + defer srv.Close() + + client := newTestClient(srv) + _, err := client.CreateChatCompletion(context.Background(), categorizer.ChatCompletionRequest{ + Model: "claude-haiku-4-5-20251001", + Messages: []categorizer.Message{{Role: "user", Content: "hi"}}, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "502") + // Categorizer's retry logic keys off the substring "502" — verify it's there. + assert.True(t, strings.Contains(err.Error(), "502")) +} + +func TestCreateChatCompletion_NoTextContentInResponse(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"content":[]}`) + })) + defer srv.Close() + + client := newTestClient(srv) + _, err := client.CreateChatCompletion(context.Background(), categorizer.ChatCompletionRequest{ + Model: "claude-haiku-4-5-20251001", + Messages: []categorizer.Message{{Role: "user", Content: "hi"}}, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "no text content") +} diff --git a/internal/adapters/clients/clients.go b/internal/adapters/clients/clients.go index 8f06d76..4669337 100644 --- a/internal/adapters/clients/clients.go +++ b/internal/adapters/clients/clients.go @@ -1,34 +1,113 @@ package clients import ( + "fmt" + "log/slog" + "strings" + "github.com/eshaffer321/monarchmoney-go/pkg/monarch" + anthropicclient "github.com/eshaffer321/monarchmoney-sync-backend/internal/adapters/clients/anthropic" + openaiclient "github.com/eshaffer321/monarchmoney-sync-backend/internal/adapters/clients/openai" "github.com/eshaffer321/monarchmoney-sync-backend/internal/domain/categorizer" "github.com/eshaffer321/monarchmoney-sync-backend/internal/infrastructure/config" ) +const ( + providerOpenAI = "openai" + providerAnthropic = "anthropic" + defaultAnthropicModel = "claude-haiku-4-5-20251001" +) + type Clients struct { Monarch *monarch.Client Categorizer *categorizer.Categorizer } func NewClients(cfg *config.Config) (*Clients, error) { - // Get API keys with fallback to alternative env var names monarchToken := cfg.GetAPIKey(cfg.Monarch.APIKey, "MONARCH_TOKEN") - openAIKey := cfg.GetAPIKey(cfg.OpenAI.APIKey, "OPENAI_API_KEY", "OPENAI_APIKEY") - // Initialize Monarch client mClient, err := monarch.NewClientWithToken(monarchToken) if err != nil { return nil, err } - // Initialize OpenAI client and cache for categorizer - openAIClient := categorizer.NewRealOpenAIClient(openAIKey) - cache := categorizer.NewMemoryCache() - cat := categorizer.NewCategorizer(openAIClient, cache, cfg.OpenAI.Model) + chatClient, model, err := newChatClient(cfg, slog.Default()) + if err != nil { + return nil, err + } + cat := categorizer.NewCategorizer(chatClient, categorizer.NewMemoryCache(), model) return &Clients{ Monarch: mClient, Categorizer: cat, }, nil } + +// newChatClient picks the configured LLM backend and returns a ChatClient plus +// the model string to hand to the categorizer. +// +// Selection rules: +// 1. cfg.Categorizer.Provider == "openai" or "anthropic" — explicit wins; key +// for that provider must be set. +// 2. Otherwise auto-detect from which API key is set. If both keys are set, +// OpenAI is preferred (keeps existing behavior) and a warning is logged +// suggesting CATEGORIZER_PROVIDER for explicitness. +// 3. If no key is set, return an error. +func newChatClient(cfg *config.Config, logger *slog.Logger) (categorizer.ChatClient, string, error) { + openKey := cfg.GetAPIKey(cfg.OpenAI.APIKey, "OPENAI_API_KEY", "OPENAI_APIKEY") + anthKey := cfg.GetAPIKey(cfg.Anthropic.APIKey, "ANTHROPIC_API_KEY", "CLAUDE_API_KEY") + provider := strings.ToLower(strings.TrimSpace(cfg.Categorizer.Provider)) + + switch provider { + case providerOpenAI: + if openKey == "" { + return nil, "", errMissingKey(providerOpenAI) + } + return openaiclient.NewClient(openKey), modelOrDefault(cfg.OpenAI.Model, categorizer.DefaultModel), nil + case providerAnthropic: + if anthKey == "" { + return nil, "", errMissingKey(providerAnthropic) + } + return anthropicclient.NewClient(anthKey), modelOrDefault(cfg.Anthropic.Model, defaultAnthropicModel), nil + case "": + // auto-detect + default: + return nil, "", fmt.Errorf("unknown categorizer provider %q (valid: openai, anthropic)", provider) + } + + hasOpen := openKey != "" + hasAnth := anthKey != "" + switch { + case hasOpen && !hasAnth: + return openaiclient.NewClient(openKey), modelOrDefault(cfg.OpenAI.Model, categorizer.DefaultModel), nil + case hasAnth && !hasOpen: + return anthropicclient.NewClient(anthKey), modelOrDefault(cfg.Anthropic.Model, defaultAnthropicModel), nil + case hasOpen && hasAnth: + logger.Warn("both OPENAI_API_KEY and ANTHROPIC_API_KEY are set; defaulting to openai. Set CATEGORIZER_PROVIDER=anthropic to pick Claude.") + return openaiclient.NewClient(openKey), modelOrDefault(cfg.OpenAI.Model, categorizer.DefaultModel), nil + default: + return nil, "", errNoLLMKeyConfigured() + } +} + +func errMissingKey(provider string) error { + switch provider { + case providerOpenAI: + return fmt.Errorf("CATEGORIZER_PROVIDER=openai but OPENAI_API_KEY is not set") + case providerAnthropic: + return fmt.Errorf("CATEGORIZER_PROVIDER=anthropic but ANTHROPIC_API_KEY (or CLAUDE_API_KEY) is not set") + default: + return fmt.Errorf("missing API key for provider %q", provider) + } +} + +func errNoLLMKeyConfigured() error { + return fmt.Errorf("no LLM API key configured — set OPENAI_API_KEY or ANTHROPIC_API_KEY") +} + +func modelOrDefault(model, fallback string) string { + if strings.TrimSpace(model) == "" { + return fallback + } + return model +} diff --git a/internal/adapters/clients/clients_test.go b/internal/adapters/clients/clients_test.go new file mode 100644 index 0000000..0e12371 --- /dev/null +++ b/internal/adapters/clients/clients_test.go @@ -0,0 +1,192 @@ +package clients + +import ( + "io" + "log/slog" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + anthropicclient "github.com/eshaffer321/monarchmoney-sync-backend/internal/adapters/clients/anthropic" + openaiclient "github.com/eshaffer321/monarchmoney-sync-backend/internal/adapters/clients/openai" + "github.com/eshaffer321/monarchmoney-sync-backend/internal/infrastructure/config" +) + +func discardLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +// clearLLMEnv removes env vars that newChatClient consults via cfg.GetAPIKey +// so each test controls inputs purely through the config struct. +func clearLLMEnv(t *testing.T) { + t.Helper() + for _, k := range []string{ + "OPENAI_API_KEY", "OPENAI_APIKEY", + "ANTHROPIC_API_KEY", "CLAUDE_API_KEY", + } { + t.Setenv(k, "") + } +} + +func TestNewChatClient_ExplicitOpenAI(t *testing.T) { + clearLLMEnv(t) + cfg := &config.Config{ + OpenAI: config.OpenAIConfig{APIKey: "open-key", Model: "gpt-test"}, + Categorizer: config.CategorizerConfig{Provider: "openai"}, + } + + client, model, err := newChatClient(cfg, discardLogger()) + + require.NoError(t, err) + _, ok := client.(*openaiclient.Client) + assert.True(t, ok, "expected openai client") + assert.Equal(t, "gpt-test", model) +} + +func TestNewChatClient_ExplicitOpenAI_MissingKey(t *testing.T) { + clearLLMEnv(t) + cfg := &config.Config{ + Categorizer: config.CategorizerConfig{Provider: "openai"}, + } + + _, _, err := newChatClient(cfg, discardLogger()) + + require.Error(t, err) + assert.Contains(t, err.Error(), "OPENAI_API_KEY") +} + +func TestNewChatClient_ExplicitAnthropic(t *testing.T) { + clearLLMEnv(t) + cfg := &config.Config{ + Anthropic: config.AnthropicConfig{APIKey: "anth-key", Model: "claude-test"}, + Categorizer: config.CategorizerConfig{Provider: "anthropic"}, + } + + client, model, err := newChatClient(cfg, discardLogger()) + + require.NoError(t, err) + _, ok := client.(*anthropicclient.Client) + assert.True(t, ok, "expected anthropic client") + assert.Equal(t, "claude-test", model) +} + +func TestNewChatClient_ExplicitAnthropic_DefaultsModelWhenMissing(t *testing.T) { + clearLLMEnv(t) + cfg := &config.Config{ + Anthropic: config.AnthropicConfig{APIKey: "anth-key"}, + Categorizer: config.CategorizerConfig{Provider: "anthropic"}, + } + + client, model, err := newChatClient(cfg, discardLogger()) + + require.NoError(t, err) + _, ok := client.(*anthropicclient.Client) + assert.True(t, ok, "expected anthropic client") + assert.Equal(t, defaultAnthropicModel, model) +} + +func TestNewChatClient_ExplicitAnthropic_MissingKey(t *testing.T) { + clearLLMEnv(t) + cfg := &config.Config{ + Categorizer: config.CategorizerConfig{Provider: "anthropic"}, + } + + _, _, err := newChatClient(cfg, discardLogger()) + + require.Error(t, err) + assert.Contains(t, err.Error(), "ANTHROPIC_API_KEY") +} + +func TestNewChatClient_UnknownProvider(t *testing.T) { + clearLLMEnv(t) + cfg := &config.Config{ + Categorizer: config.CategorizerConfig{Provider: "gemini"}, + } + + _, _, err := newChatClient(cfg, discardLogger()) + + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown categorizer provider") +} + +func TestNewChatClient_AutoDetect_OpenAIOnly(t *testing.T) { + clearLLMEnv(t) + cfg := &config.Config{ + OpenAI: config.OpenAIConfig{APIKey: "open-key", Model: "gpt-test"}, + } + + client, model, err := newChatClient(cfg, discardLogger()) + + require.NoError(t, err) + _, ok := client.(*openaiclient.Client) + assert.True(t, ok) + assert.Equal(t, "gpt-test", model) +} + +func TestNewChatClient_AutoDetect_AnthropicOnly(t *testing.T) { + clearLLMEnv(t) + cfg := &config.Config{ + Anthropic: config.AnthropicConfig{APIKey: "anth-key", Model: "claude-test"}, + } + + client, model, err := newChatClient(cfg, discardLogger()) + + require.NoError(t, err) + _, ok := client.(*anthropicclient.Client) + assert.True(t, ok) + assert.Equal(t, "claude-test", model) +} + +func TestNewChatClient_AutoDetect_AnthropicOnly_DefaultsModelWhenMissing(t *testing.T) { + clearLLMEnv(t) + cfg := &config.Config{ + Anthropic: config.AnthropicConfig{APIKey: "anth-key"}, + } + + client, model, err := newChatClient(cfg, discardLogger()) + + require.NoError(t, err) + _, ok := client.(*anthropicclient.Client) + assert.True(t, ok) + assert.Equal(t, defaultAnthropicModel, model) +} + +func TestNewChatClient_AutoDetect_BothKeysPrefersOpenAI(t *testing.T) { + clearLLMEnv(t) + cfg := &config.Config{ + OpenAI: config.OpenAIConfig{APIKey: "open-key", Model: "gpt-test"}, + Anthropic: config.AnthropicConfig{APIKey: "anth-key", Model: "claude-test"}, + } + + client, model, err := newChatClient(cfg, discardLogger()) + + require.NoError(t, err) + _, ok := client.(*openaiclient.Client) + assert.True(t, ok, "both keys set without explicit provider should default to openai") + assert.Equal(t, "gpt-test", model) +} + +func TestNewChatClient_AutoDetect_NoKeys(t *testing.T) { + clearLLMEnv(t) + cfg := &config.Config{} + + _, _, err := newChatClient(cfg, discardLogger()) + + require.Error(t, err) + assert.Contains(t, err.Error(), "no LLM API key configured") +} + +func TestNewChatClient_CaseInsensitiveProvider(t *testing.T) { + clearLLMEnv(t) + cfg := &config.Config{ + Anthropic: config.AnthropicConfig{APIKey: "anth-key", Model: "claude-test"}, + Categorizer: config.CategorizerConfig{Provider: " Anthropic "}, + } + + client, _, err := newChatClient(cfg, discardLogger()) + + require.NoError(t, err) + _, ok := client.(*anthropicclient.Client) + assert.True(t, ok) +} diff --git a/internal/domain/categorizer/openai_client.go b/internal/adapters/clients/openai/client.go similarity index 66% rename from internal/domain/categorizer/openai_client.go rename to internal/adapters/clients/openai/client.go index 424e02b..2331809 100644 --- a/internal/domain/categorizer/openai_client.go +++ b/internal/adapters/clients/openai/client.go @@ -1,4 +1,5 @@ -package categorizer +// Package openai contains the OpenAI HTTP adapter implementing categorizer.ChatClient. +package openai import ( "bytes" @@ -8,18 +9,20 @@ import ( "io" "net/http" "time" + + "github.com/eshaffer321/monarchmoney-sync-backend/internal/domain/categorizer" ) -// RealOpenAIClient implements the OpenAIClient interface for actual API calls -type RealOpenAIClient struct { +// Client is the HTTP-backed OpenAI implementation of categorizer.ChatClient. +type Client struct { apiKey string httpClient *http.Client baseURL string } -// NewRealOpenAIClient creates a new OpenAI client -func NewRealOpenAIClient(apiKey string) *RealOpenAIClient { - return &RealOpenAIClient{ +// NewClient creates a new OpenAI client. +func NewClient(apiKey string) *Client { + return &Client{ apiKey: apiKey, baseURL: "https://api.openai.com/v1", httpClient: &http.Client{ @@ -28,38 +31,31 @@ func NewRealOpenAIClient(apiKey string) *RealOpenAIClient { } } -// CreateChatCompletion makes a chat completion request to OpenAI -func (c *RealOpenAIClient) CreateChatCompletion(ctx context.Context, request ChatCompletionRequest) (*ChatCompletionResponse, error) { - // Marshal request +// CreateChatCompletion calls the OpenAI chat-completions endpoint. +func (c *Client) CreateChatCompletion(ctx context.Context, request categorizer.ChatCompletionRequest) (*categorizer.ChatCompletionResponse, error) { requestBody, err := json.Marshal(request) if err != nil { return nil, fmt.Errorf("failed to marshal request: %w", err) } - // Create HTTP request - req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/chat/completions", bytes.NewBuffer(requestBody)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/chat/completions", bytes.NewBuffer(requestBody)) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } - - // Set headers req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+c.apiKey) - // Make request resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to make request: %w", err) } defer func() { _ = resp.Body.Close() }() - // Read response body body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response: %w", err) } - // Check for errors if resp.StatusCode != http.StatusOK { var errorResp struct { Error struct { @@ -68,20 +64,16 @@ func (c *RealOpenAIClient) CreateChatCompletion(ctx context.Context, request Cha Code string `json:"code"` } `json:"error"` } - if err := json.Unmarshal(body, &errorResp); err == nil && errorResp.Error.Message != "" { return nil, fmt.Errorf("OpenAI API error: %s (type: %s, code: %s)", errorResp.Error.Message, errorResp.Error.Type, errorResp.Error.Code) } - return nil, fmt.Errorf("OpenAI API returned status %d: %s", resp.StatusCode, string(body)) } - // Parse response - var response ChatCompletionResponse + var response categorizer.ChatCompletionResponse if err := json.Unmarshal(body, &response); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) } - return &response, nil } diff --git a/internal/adapters/clients/openai/client_test.go b/internal/adapters/clients/openai/client_test.go new file mode 100644 index 0000000..6457f1a --- /dev/null +++ b/internal/adapters/clients/openai/client_test.go @@ -0,0 +1,80 @@ +package openai + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/eshaffer321/monarchmoney-sync-backend/internal/domain/categorizer" +) + +// newTestClient returns a Client pointed at the given test server. +func newTestClient(srv *httptest.Server) *Client { + c := NewClient("test-key") + c.baseURL = srv.URL + return c +} + +func TestCreateChatCompletion_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/chat/completions", r.URL.Path) + assert.Equal(t, "Bearer test-key", r.Header.Get("Authorization")) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"choices":[{"message":{"role":"assistant","content":"hello"}}]}`) + })) + defer srv.Close() + + client := newTestClient(srv) + resp, err := client.CreateChatCompletion(context.Background(), categorizer.ChatCompletionRequest{ + Model: "gpt-test", + Messages: []categorizer.Message{{Role: "user", Content: "hi"}}, + }) + + require.NoError(t, err) + require.NotNil(t, resp) + require.Len(t, resp.Choices, 1) + assert.Equal(t, "hello", resp.Choices[0].Message.Content) +} + +func TestCreateChatCompletion_StructuredErrorResponse(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = io.WriteString(w, `{"error":{"message":"invalid api key","type":"invalid_request_error","code":"invalid_api_key"}}`) + })) + defer srv.Close() + + client := newTestClient(srv) + _, err := client.CreateChatCompletion(context.Background(), categorizer.ChatCompletionRequest{ + Model: "gpt-test", + Messages: []categorizer.Message{{Role: "user", Content: "hi"}}, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "OpenAI API error") + assert.Contains(t, err.Error(), "invalid api key") +} + +func TestCreateChatCompletion_OpaqueServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadGateway) + _, _ = io.WriteString(w, "upstream borked") + })) + defer srv.Close() + + client := newTestClient(srv) + _, err := client.CreateChatCompletion(context.Background(), categorizer.ChatCompletionRequest{ + Model: "gpt-test", + Messages: []categorizer.Message{{Role: "user", Content: "hi"}}, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "502") +} diff --git a/internal/domain/categorizer/categorizer.go b/internal/domain/categorizer/categorizer.go index 92029cc..a59b0b0 100644 --- a/internal/domain/categorizer/categorizer.go +++ b/internal/domain/categorizer/categorizer.go @@ -35,7 +35,10 @@ type CategorizationResult struct { Categorizations []ItemCategorization `json:"categorizations"` } -// OpenAI API types +// Chat-completion request/response types. +// The shape mirrors OpenAI's chat-completions API and is treated as the +// shared vocabulary that adapters translate to and from. Adapters for +// non-OpenAI backends (e.g. Anthropic) consume the same types. type ChatCompletionRequest struct { Model string `json:"model"` Messages []Message `json:"messages"` @@ -61,8 +64,9 @@ type Choice struct { Message Message `json:"message"` } -// OpenAIClient interface for OpenAI API calls -type OpenAIClient interface { +// ChatClient is the LLM-backend interface the categorizer depends on. +// Concrete implementations live in internal/adapters/clients/{openai,anthropic}. +type ChatClient interface { CreateChatCompletion(ctx context.Context, request ChatCompletionRequest) (*ChatCompletionResponse, error) } @@ -72,15 +76,15 @@ type Cache interface { Set(key string, value string) } -// Categorizer handles item categorization using OpenAI +// Categorizer handles item categorization using a pluggable LLM backend. type Categorizer struct { - client OpenAIClient + client ChatClient cache Cache Model string } // NewCategorizer creates a new categorizer -func NewCategorizer(client OpenAIClient, cache Cache, model string) *Categorizer { +func NewCategorizer(client ChatClient, cache Cache, model string) *Categorizer { if strings.TrimSpace(model) == "" { model = DefaultModel } @@ -135,14 +139,14 @@ func (c *Categorizer) CategorizeItems(ctx context.Context, items []Item, categor return result, nil } - // Call OpenAI for uncached items - openAIResult, err := c.callOpenAI(ctx, uncachedItems, categories) + // Call the LLM for uncached items + llmResult, err := c.callLLM(ctx, uncachedItems, categories) if err != nil { - return nil, fmt.Errorf("OpenAI categorization failed: %w", err) + return nil, fmt.Errorf("LLM categorization failed: %w", err) } - // Process OpenAI results - for _, cat := range openAIResult.Categorizations { + // Process LLM results + for _, cat := range llmResult.Categorizations { // Cache the result normalizedName := c.normalizeItemName(cat.ItemName) c.cache.Set(normalizedName, cat.CategoryID) @@ -181,8 +185,8 @@ func isRetryableError(err error) bool { strings.Contains(errMsg, "504") } -// callOpenAI makes the actual API call to OpenAI with retry logic -func (c *Categorizer) callOpenAI(ctx context.Context, items []Item, categories []Category) (*CategorizationResult, error) { +// callLLM makes the actual API call to the LLM with retry logic +func (c *Categorizer) callLLM(ctx context.Context, items []Item, categories []Category) (*CategorizationResult, error) { prompt := c.buildPrompt(items, categories) request := ChatCompletionRequest{ @@ -227,13 +231,13 @@ func (c *Categorizer) callOpenAI(ctx context.Context, items []Item, categories [ } if len(response.Choices) == 0 { - return nil, fmt.Errorf("no response from OpenAI") + return nil, fmt.Errorf("no response from LLM") } // Parse JSON response var result CategorizationResult if err := json.Unmarshal([]byte(response.Choices[0].Message.Content), &result); err != nil { - return nil, fmt.Errorf("failed to parse OpenAI response: %w", err) + return nil, fmt.Errorf("failed to parse LLM response: %w", err) } return &result, nil diff --git a/internal/domain/categorizer/categorizer_test.go b/internal/domain/categorizer/categorizer_test.go index 3382276..b0104ce 100644 --- a/internal/domain/categorizer/categorizer_test.go +++ b/internal/domain/categorizer/categorizer_test.go @@ -10,12 +10,12 @@ import ( "github.com/stretchr/testify/require" ) -// MockOpenAIClient for testing -type MockOpenAIClient struct { +// MockChatClient for testing — implements categorizer.ChatClient. +type MockChatClient struct { mock.Mock } -func (m *MockOpenAIClient) CreateChatCompletion(ctx context.Context, request ChatCompletionRequest) (*ChatCompletionResponse, error) { +func (m *MockChatClient) CreateChatCompletion(ctx context.Context, request ChatCompletionRequest) (*ChatCompletionResponse, error) { args := m.Called(ctx, request) if response := args.Get(0); response != nil { return response.(*ChatCompletionResponse), args.Error(1) @@ -40,7 +40,7 @@ func (m *MockCache) Set(key string, value string) { func TestCategorizer_CategorizeItems_Success(t *testing.T) { ctx := context.Background() - mockClient := new(MockOpenAIClient) + mockClient := new(MockChatClient) mockCache := new(MockCache) categorizer := NewCategorizer(mockClient, mockCache, "gpt-4o-mini") @@ -119,7 +119,7 @@ func TestCategorizer_CategorizeItems_Success(t *testing.T) { func TestCategorizer_CategorizeItems_WithCache(t *testing.T) { ctx := context.Background() - mockClient := new(MockOpenAIClient) + mockClient := new(MockChatClient) mockCache := new(MockCache) categorizer := NewCategorizer(mockClient, mockCache, "") @@ -160,7 +160,7 @@ func TestCategorizer_CategorizeItems_WithCache(t *testing.T) { func TestCategorizer_CategorizeItems_PartialCache(t *testing.T) { ctx := context.Background() - mockClient := new(MockOpenAIClient) + mockClient := new(MockChatClient) mockCache := new(MockCache) categorizer := NewCategorizer(mockClient, mockCache, "gpt-5.4-nano") @@ -232,7 +232,7 @@ func TestCategorizer_CategorizeItems_PartialCache(t *testing.T) { func TestCategorizer_CategorizeItems_EmptyItems(t *testing.T) { ctx := context.Background() - mockClient := new(MockOpenAIClient) + mockClient := new(MockChatClient) mockCache := new(MockCache) categorizer := NewCategorizer(mockClient, mockCache, "") @@ -252,7 +252,7 @@ func TestCategorizer_CategorizeItems_EmptyItems(t *testing.T) { func TestCategorizer_CategorizeItems_OpenAIError(t *testing.T) { ctx := context.Background() - mockClient := new(MockOpenAIClient) + mockClient := new(MockChatClient) mockCache := new(MockCache) categorizer := NewCategorizer(mockClient, mockCache, "") @@ -332,7 +332,7 @@ func TestCategorizer_NormalizeItemName(t *testing.T) { func TestCategorizer_CategorizeItems_RetriesOnTransientError(t *testing.T) { ctx := context.Background() - mockClient := new(MockOpenAIClient) + mockClient := new(MockChatClient) mockCache := new(MockCache) categorizer := NewCategorizer(mockClient, mockCache, "") @@ -388,7 +388,7 @@ func TestCategorizer_CategorizeItems_RetriesOnTransientError(t *testing.T) { func TestCategorizer_CategorizeItems_ExhaustsRetries(t *testing.T) { ctx := context.Background() - mockClient := new(MockOpenAIClient) + mockClient := new(MockChatClient) mockCache := new(MockCache) categorizer := NewCategorizer(mockClient, mockCache, "") @@ -421,7 +421,7 @@ func TestCategorizer_CategorizeItems_ExhaustsRetries(t *testing.T) { } func TestNewCategorizer_DefaultsModelWhenEmpty(t *testing.T) { - categorizer := NewCategorizer(new(MockOpenAIClient), new(MockCache), "") + categorizer := NewCategorizer(new(MockChatClient), new(MockCache), "") assert.Equal(t, "gpt-5.4-nano", categorizer.Model) } diff --git a/internal/infrastructure/config/config.go b/internal/infrastructure/config/config.go index f2fe0a5..3fa65db 100644 --- a/internal/infrastructure/config/config.go +++ b/internal/infrastructure/config/config.go @@ -23,6 +23,8 @@ type Config struct { Providers ProvidersConfig `yaml:"providers"` Monarch MonarchConfig `yaml:"monarch"` OpenAI OpenAIConfig `yaml:"openai"` + Anthropic AnthropicConfig `yaml:"anthropic"` + Categorizer CategorizerConfig `yaml:"categorizer"` Storage StorageConfig `yaml:"storage"` Observability ObservabilityConfig `yaml:"observability"` } @@ -43,6 +45,19 @@ type OpenAIConfig struct { Model string `yaml:"model"` } +// AnthropicConfig holds Anthropic (Claude) API configuration +type AnthropicConfig struct { + APIKey string `yaml:"api_key"` + Model string `yaml:"model"` +} + +// CategorizerConfig selects which LLM backend the categorizer uses. +// Provider may be "openai", "anthropic", or "" (auto-detect from which +// API key is set). +type CategorizerConfig struct { + Provider string `yaml:"provider"` +} + // ProvidersConfig holds provider-specific configuration type ProvidersConfig struct { Walmart WalmartConfig `yaml:"walmart"` @@ -124,6 +139,13 @@ func LoadFromEnv() *Config { APIKey: os.Getenv("OPENAI_API_KEY"), Model: getEnv("OPENAI_MODEL", "gpt-5.4-nano"), }, + Anthropic: AnthropicConfig{ + APIKey: firstNonEmpty(os.Getenv("ANTHROPIC_API_KEY"), os.Getenv("CLAUDE_API_KEY")), + Model: getEnv("ANTHROPIC_MODEL", "claude-haiku-4-5-20251001"), + }, + Categorizer: CategorizerConfig{ + Provider: os.Getenv("CATEGORIZER_PROVIDER"), + }, Providers: ProvidersConfig{ Walmart: WalmartConfig{ Enabled: true, @@ -164,6 +186,16 @@ func LoadOrEnv_WithPath(path string) *Config { return LoadFromEnv() } +// firstNonEmpty returns the first non-empty string from its arguments. +func firstNonEmpty(values ...string) string { + for _, v := range values { + if v != "" { + return v + } + } + return "" +} + // getEnv retrieves an environment variable with a fallback default func getEnv(key, fallback string) string { if val := os.Getenv(key); val != "" {