CommitBrief reviews your git diff with whatever LLM you point it at — Claude, GPT, Gemini, a local Ollama model, or the claude CLI you already have installed. Ten providers, one Provider interface, zero special-casing in the review pipeline. This post is how that abstraction is built, because the providers are far less alike than "they're all LLMs" suggests.
The ten split into three transport classes that share almost nothing at the wire level: native HTTPS APIs, OpenAI-compatible endpoints, and local subprocesses. Making them satisfy one interface — without the pipeline knowing which is which — is the whole trick.
TL;DR
-
One interface, ten implementations. Every provider satisfies a 7-method
Providerinterface; the pipeline never type-switches on a vendor. -
A
database/sql-style registry. Each provider registers itself ininit(); a blank import inmain.gois all it takes to add one. - Three transport classes. Native APIs, OpenAI-compatible endpoints (reusing one SDK via a base URL), and subprocess-backed CLIs — the last opt out of the JSON contract through a marker interface.
- The limit. Provider-agnostic is not provider-equal. A local model is a real review, not a frontier one — and the eval harness measures the gap instead of hiding it.
Key facts
| Class | Providers | Transport |
|---|---|---|
| Native API |
anthropic, openai, gemini, ollama
|
Each vendor's own HTTPS API (Ollama over localhost) |
| OpenAI-compatible |
deepseek, mistral, cohere
|
openai-go SDK pointed at the vendor's base URL |
| CLI-backed |
claude-cli, gemini-cli, codex-cli
|
A local subprocess; reuses the host CLI's auth |
The interface every provider satisfies
This is the entire contract. Seven methods, no generics, no per-vendor escape hatch:
type Provider interface {
Name() string
DefaultModel() string
ContextWindow(model string) int
EstimateTokens(text string) int
Pricing(model string) Pricing
Review(ctx context.Context, req Request) (Response, error)
TestConnection(ctx context.Context) error
}
Review does the work; the other six let the pipeline make decisions before the call — estimate tokens for the cost preflight, look up Pricing to warn over a threshold, check ContextWindow to catch an oversized prompt, and TestConnection so commitbrief providers test <name> can ping a key without running a review. The pipeline holds a Provider and never asks which concrete type it is.
A registry you've already used
If you've ever written _ "github.com/lib/pq" to register a database driver, you know this pattern. Providers register themselves; nothing imports them by name.
type Factory func(cfg config.ProviderConfig) (Provider, error)
func Register(name string, factory Factory) {
if name == "" {
panic("provider: Register called with empty name")
}
if factory == nil {
panic("provider: Register called with nil factory for " + name)
}
registryMu.Lock()
defer registryMu.Unlock()
if _, exists := registry[name]; exists {
panic("provider: duplicate registration for " + name)
}
registry[name] = factory
}
The panics are deliberate. A duplicate name or a nil factory is a programmer error, and it should crash at startup — when an init() runs — not silently shadow a provider that a user later selects. Each provider subpackage calls Register in its own init(), so wiring a new one into the binary is one line:
import (
_ "github.com/CommitBrief/commitbrief/internal/provider/anthropic"
_ "github.com/CommitBrief/commitbrief/internal/provider/deepseek"
_ "github.com/CommitBrief/commitbrief/internal/provider/claude-cli"
// ...one blank import per provider
)
New(name, cfg) looks the factory up under a read lock and returns a typed error listing the known names when you ask for one that isn't there. That's the seam every transport class plugs into.
Class 1 — native APIs
anthropic, openai, and gemini each talk to their vendor's own SDK and use that vendor's native structured-output mechanism. ollama is the same shape pointed at http://localhost:11434: same interface, but the diff never leaves the machine and the cost is zero. For a contractor under an NDA, that last property is the entire point — commitbrief --provider ollama is a real review with no third-party egress.
(How each vendor is coerced into returning structured findings — tool_use, strict json_schema, responseSchema — is its own story. That's the next post in the series.)
Class 2 — OpenAI-compatible, for the cost of a base URL
DeepSeek, Mistral, and Cohere all expose an OpenAI-shaped Chat Completions API. So instead of three new SDKs, they reuse the one already in the build — github.com/openai/openai-go — pointed at a different host:
func New(cfg config.ProviderConfig) (provider.Provider, error) {
if cfg.APIKey == "" {
return nil, fmt.Errorf("deepseek: %w", provider.ErrUnauthorized)
}
baseURL := cfg.BaseURL
if baseURL == "" {
baseURL = defaultBaseURL // https://api.deepseek.com
}
return &Client{
sdk: sdk.NewClient(option.WithAPIKey(cfg.APIKey), option.WithBaseURL(baseURL)),
model: cfg.Model,
}, nil
}
Three providers, one option.WithBaseURL, no new dependency to license-audit or keep current. Cohere even ships its compatibility surface at api.cohere.ai/compatibility/v1, so it slots in the same way. These three don't request a strict response format — support for it is uneven — so their JSON shape comes from the prompt contract plus a retry-once-then-degrade fallback, the same way Ollama works.
Class 3 — subprocess CLIs, and a marker interface
The third class is the unusual one. claude-cli, gemini-cli, and codex-cli don't make HTTP calls at all — they shell out to a CLI you already have on your PATH and reuse its auth. If you pay for a Claude or Gemini subscription, reviewing a diff through it costs nothing extra.
These three differ only in their binary name and a few flags, so they share one clireview.Backend. claude-cli, for instance, pipes the prompt on stdin:
claude -p - --output-format text
Two details are worth pulling out.
They opt out of the JSON contract via a marker interface. A CLI tool has already formatted its output; forcing it through JSON parsing and the cards renderer would mangle it. So the backend implements a marker:
// PlainTextEmitter is the marker interface for providers whose
// Review() returns formatted plain text instead of structured JSON.
type PlainTextEmitter interface {
Provider
EmitsPlainText()
}
func (b *Backend) EmitsPlainText() {} // the whole implementation
The pipeline asks once whether the provider has the capability — the same type-assertion idiom as http.Flusher or io.WriterTo — and reuses the answer:
// claude-cli / gemini-cli / codex-cli get the plain-text prompt
// contract instead of the JSON one — the host CLI's agentic system
// prompt makes structured-output guarantees unreliable.
_, plainText := prov.(provider.PlainTextEmitter)
var p prompt.Prompt
if plainText {
p = prompt.BuildPlainText(loaded, app.Lang, numberedDiff, archContext, global.withContext)
} else {
p = prompt.Build(loaded, app.Lang, numberedDiff, archContext)
}
That single plainText bool then drives the few places the two paths diverge: which prompt contract to build, whether to run the deterministic flaky-test pre-pass (skipped for CLI tools), and whether to stream the response verbatim or parse it as JSON. There's no switch over provider names anywhere in the pipeline — routing is on the capability, not the identity. A new plain-text provider implements EmitsPlainText() and inherits all of it.
DefaultModel() returns the binary's version, on purpose. The cache key includes the model string. A CLI provider has no model name in the API sense, so it reports binary + detected version — queried once with --version and memoized behind a sync.Once, because DefaultModel() is on the hot path of every cache-key computation:
func (b *Backend) DefaultModel() string {
if v := b.versionOrEmpty(); v != "" {
return b.spec.Binary + " " + v
}
return b.spec.Binary
}
func (b *Backend) ContextWindow(string) int { return 200_000 } // informational
When you upgrade the host CLI, the version string changes, so cached reviews from the old version cleanly invalidate. Correctness falls out of the cache key for free.
Choosing one
Selection is explicit — one provider per run, picked by flag or config:
commitbrief --provider openai # override the configured provider
commitbrief --cli claude # shorthand for --provider claude-cli
commitbrief providers use ollama --local # set the default for this repo
commitbrief providers test deepseek # ping the key, no review
A few combinations are rejected on purpose: --provider and --cli are mutually exclusive, and --cli can't pair with --json or --markdown because a plain-text provider doesn't produce the structured output those formats render.
What it is not
Provider-agnostic is not provider-equal. A local qwen2.5-coder review is a real second pass and beats no review, but it won't match a frontier model on subtle findings. CommitBrief doesn't paper over that — the eval harness scores each model against a known-answer corpus so you can see the gap yourself:
COMMITBRIEF_EVAL_PROVIDER=<name> make eval-live
And there's no automatic vendor-to-vendor failover. CommitBrief talks to exactly one provider per run; switching is a flag or a config write, never a silent retry against a different company's API on your diff. That's a deliberate choice about who decides where your code goes — you, every time.
Part 2 of **Building CommitBrief. Next: getting structured findings out of five incompatible LLM APIs — tool_use, strict json_schema, responseSchema, prompt-driven JSON — and degrading gracefully when the model ignores all of them.
Top comments (0)