feat: add Anthropic (Claude) as a categorizer backend#32
Merged
eshaffer321 merged 2 commits intoJun 20, 2026
Conversation
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) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #32 +/- ##
==========================================
+ Coverage 56.78% 58.07% +1.29%
==========================================
Files 43 44 +1
Lines 4857 4995 +138
==========================================
+ Hits 2758 2901 +143
+ Misses 1934 1918 -16
- Partials 165 176 +11
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Makes the LLM categorizer backend pluggable so itemize can use either OpenAI (existing) or Anthropic Claude. Motivated by the
# CLAUDE_API_KEY=your-claude-api-keyplaceholder already sitting in.env.example— the integration point was anticipated; this wires it up.Design
Architectural moves (no behavior change for OpenAI users):
categorizer.OpenAIClient→categorizer.ChatClient. The contract is provider-agnostic; the name should be too.internal/domain/categorizer/openai_client.gointo a newinternal/adapters/clients/openai/package. Per AGENTS.md, the domain layer must stay pure (no HTTP/IO) — putting Anthropic alongside OpenAI indomain/would compound an existing violation, while putting Anthropic inadapters/and leaving OpenAI indomain/would be inconsistent. Cleanest fix is to move OpenAI out too. Happy to drop this move if you'd rather keep the diff smaller — the rest of the PR still works withopenai_client.goleft where it is.New code:
internal/adapters/clients/anthropic/— implementscategorizer.ChatClientvia Anthropic's Messages API. Rawnet/http, mirroring the existing OpenAI client's shape (no new SDK dependency).response_format: json_object. When the categorizer asks for JSON, the adapter appends an assistant message with content{to the request — Claude continues from the prefill and emits a JSON object, which the adapter reassembles into the existingChatCompletionResponseshape. No schema coupling, no tool definitions, no consumer changes.AnthropicConfigandCategorizerConfigininternal/infrastructure/config/config.go.newChatClientfactory ininternal/adapters/clients/clients.go. Selection rules:CATEGORIZER_PROVIDER=openai|anthropicwins.Defaults
claude-haiku-4-5-20251001— chosen for speed/cost parity with the existinggpt-5.4-nanodefault.ANTHROPIC_API_KEY(alsoCLAUDE_API_KEY),ANTHROPIC_MODEL,CATEGORIZER_PROVIDER. All optional; existing OpenAI-only setups continue to work unchanged.Backwards compatibility
OPENAI_API_KEY/OPENAI_APIKEY/OPENAI_MODELpaths are untouched.config.yamlfiles withoutanthropic/categorizerblocks still work.categorizer.Categorizerconstructor andCategorizeItemssignatures are unchanged.Tests
internal/adapters/clients/anthropicinternal/adapters/clients/openaiinternal/adapters/clientsnewChatClient: explicit / auto-detect / precedence / missing-key / unknown providerinternal/domain/categorizerMockOpenAIClient→MockChatClientgo test ./... -racepasses;golangci-lint runclean for files in this PR (two pre-existingQF1012warnings incategorizer.go:257,262are present onmainalready and untouched here).Test plan
go test ./... -race -cover— all greengo vet ./...— cleangolangci-lint run ./...— clean for this PR's filesgo build ./...— cleanOPENAI_API_KEY=... ./itemize walmart -dry-run -days 14 -verboseANTHROPIC_API_KEY=... CATEGORIZER_PROVIDER=anthropic ./itemize walmart -dry-run -days 14 -verboseCATEGORIZER_PROVIDER→ picks OpenAI + logs warning(Live smokes A and B require accounts; happy to run them and report back if useful, or leave them to your discretion.)
Out of scope (intentionally not included)
OLLAMA_ENDPOINTline in.env.exampleis preserved but not wired. Separate work.anthropic-sdk-go— kept rawnet/httpto mirror the OpenAI client and avoid adding a dependency. Easy to swap later.Notes for the reviewer
openai_client.goindomain/) and update the import inclients.go. The Anthropic addition still stands on its own.claude-haiku-4-5-20251001isn't the model you'd default to, easy one-line change inLoadFromEnv().newChatClient.