Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 10 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`
Expand Down
27 changes: 25 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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`:
Expand All @@ -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
Expand Down
12 changes: 12 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
180 changes: 180 additions & 0 deletions internal/adapters/clients/anthropic/client.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading