From 10c81e108f4a18a94794c364b9ff28bcd479290f Mon Sep 17 00:00:00 2001 From: Frankie Ziman Date: Tue, 16 Jun 2026 20:05:20 -0400 Subject: [PATCH 1/2] feat: add Anthropic (Claude) as a categorizer backend The categorizer already depended on a chat-completion interface (misleadingly named OpenAIClient); only the concrete OpenAI HTTP implementation was hardcoded. This makes the backend pluggable so users can pick OpenAI or Claude via config or env vars, mirroring the placeholder already present in .env.example. Architectural moves (mechanical, no behavior change): * Rename categorizer.OpenAIClient -> categorizer.ChatClient (the contract is provider-agnostic, the name should be too). * Move the concrete OpenAI HTTP client out of internal/domain/categorizer/ into a new internal/adapters/clients/openai/ package, renaming RealOpenAIClient -> openai.Client. Per AGENTS.md the domain layer must stay pure; putting Anthropic alongside OpenAI in domain/ would compound the existing violation. New code: * internal/adapters/clients/anthropic/ implements categorizer.ChatClient via the Messages API. When the categorizer asks for JSON (response_format json_object), the adapter prefills the assistant turn with "{" so Claude emits guaranteed-shape JSON the categorizer can json.Unmarshal unchanged. * AnthropicConfig and CategorizerConfig structs in config.go. * newChatClient factory in clients.go: explicit CATEGORIZER_PROVIDER wins; otherwise auto-detect from whichever key is set; if both are set, OpenAI is preferred with a warn log (preserves status quo). Defaults: * Anthropic model: claude-haiku-4-5-20251001 (speed/cost parity with the existing gpt-5.4-nano default). * Env vars: ANTHROPIC_API_KEY (CLAUDE_API_KEY also accepted), ANTHROPIC_MODEL, CATEGORIZER_PROVIDER. Existing OPENAI_* setups continue to work unchanged. Tests: * anthropic/client_test.go: 90% coverage. Mock HTTP server, success / prefill+JSON / structured error / opaque 5xx / empty content. * openai/client_test.go: 81% coverage. Parity tests for the moved code. * clients_test.go: every selection branch (explicit/auto/precedence/error). * categorizer tests pass unchanged (mock renamed MockOpenAIClient -> MockChatClient). Docs: * README: "Choosing an LLM backend" subsection. * AGENTS.md: env vars + architecture note that the categorizer is pluggable and where the concrete backends live. * config.yaml + .env.example: anthropic / categorizer blocks added. Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 20 +- AGENTS.md | 14 +- README.md | 27 ++- config.yaml | 12 ++ internal/adapters/clients/anthropic/client.go | 180 ++++++++++++++++ .../adapters/clients/anthropic/client_test.go | 196 ++++++++++++++++++ internal/adapters/clients/clients.go | 85 +++++++- internal/adapters/clients/clients_test.go | 163 +++++++++++++++ .../clients/openai/client.go} | 34 ++- .../adapters/clients/openai/client_test.go | 80 +++++++ internal/domain/categorizer/categorizer.go | 34 +-- .../domain/categorizer/categorizer_test.go | 22 +- internal/infrastructure/config/config.go | 32 +++ 13 files changed, 833 insertions(+), 66 deletions(-) create mode 100644 internal/adapters/clients/anthropic/client.go create mode 100644 internal/adapters/clients/anthropic/client_test.go create mode 100644 internal/adapters/clients/clients_test.go rename internal/{domain/categorizer/openai_client.go => adapters/clients/openai/client.go} (66%) create mode 100644 internal/adapters/clients/openai/client_test.go 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..cb215c6 100644 --- a/internal/adapters/clients/clients.go +++ b/internal/adapters/clients/clients.go @@ -1,34 +1,105 @@ 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" +) + 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), cfg.OpenAI.Model, nil + case providerAnthropic: + if anthKey == "" { + return nil, "", errMissingKey(providerAnthropic) + } + return anthropicclient.NewClient(anthKey), cfg.Anthropic.Model, 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), cfg.OpenAI.Model, nil + case hasAnth && !hasOpen: + return anthropicclient.NewClient(anthKey), cfg.Anthropic.Model, 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), cfg.OpenAI.Model, 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") +} diff --git a/internal/adapters/clients/clients_test.go b/internal/adapters/clients/clients_test.go new file mode 100644 index 0000000..c2f56eb --- /dev/null +++ b/internal/adapters/clients/clients_test.go @@ -0,0 +1,163 @@ +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_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_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 != "" { From bd552e1308d912d06e459d822b0ac6ca2bd4870c Mon Sep 17 00:00:00 2001 From: Erick Shaffer Date: Sat, 20 Jun 2026 14:26:36 -0600 Subject: [PATCH 2/2] fix: default Anthropic categorizer model --- internal/adapters/clients/clients.go | 22 +++++++++++------ internal/adapters/clients/clients_test.go | 29 +++++++++++++++++++++++ 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/internal/adapters/clients/clients.go b/internal/adapters/clients/clients.go index cb215c6..4669337 100644 --- a/internal/adapters/clients/clients.go +++ b/internal/adapters/clients/clients.go @@ -13,8 +13,9 @@ import ( ) const ( - providerOpenAI = "openai" - providerAnthropic = "anthropic" + providerOpenAI = "openai" + providerAnthropic = "anthropic" + defaultAnthropicModel = "claude-haiku-4-5-20251001" ) type Clients struct { @@ -62,12 +63,12 @@ func newChatClient(cfg *config.Config, logger *slog.Logger) (categorizer.ChatCli if openKey == "" { return nil, "", errMissingKey(providerOpenAI) } - return openaiclient.NewClient(openKey), cfg.OpenAI.Model, nil + return openaiclient.NewClient(openKey), modelOrDefault(cfg.OpenAI.Model, categorizer.DefaultModel), nil case providerAnthropic: if anthKey == "" { return nil, "", errMissingKey(providerAnthropic) } - return anthropicclient.NewClient(anthKey), cfg.Anthropic.Model, nil + return anthropicclient.NewClient(anthKey), modelOrDefault(cfg.Anthropic.Model, defaultAnthropicModel), nil case "": // auto-detect default: @@ -78,12 +79,12 @@ func newChatClient(cfg *config.Config, logger *slog.Logger) (categorizer.ChatCli hasAnth := anthKey != "" switch { case hasOpen && !hasAnth: - return openaiclient.NewClient(openKey), cfg.OpenAI.Model, nil + return openaiclient.NewClient(openKey), modelOrDefault(cfg.OpenAI.Model, categorizer.DefaultModel), nil case hasAnth && !hasOpen: - return anthropicclient.NewClient(anthKey), cfg.Anthropic.Model, nil + 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), cfg.OpenAI.Model, nil + return openaiclient.NewClient(openKey), modelOrDefault(cfg.OpenAI.Model, categorizer.DefaultModel), nil default: return nil, "", errNoLLMKeyConfigured() } @@ -103,3 +104,10 @@ func errMissingKey(provider string) error { 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 index c2f56eb..0e12371 100644 --- a/internal/adapters/clients/clients_test.go +++ b/internal/adapters/clients/clients_test.go @@ -71,6 +71,21 @@ func TestNewChatClient_ExplicitAnthropic(t *testing.T) { 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{ @@ -123,6 +138,20 @@ func TestNewChatClient_AutoDetect_AnthropicOnly(t *testing.T) { 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{