DEV Community

Muhammet ŞAFAK
Muhammet ŞAFAK

Posted on

One Go interface, ten LLMs, three transport classes

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 Provider interface; the pipeline never type-switches on a vendor.
  • A database/sql-style registry. Each provider registers itself in init(); a blank import in main.go is 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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
)
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)