One Interface for 20 LLM Providers: The Adapter Pattern in Practice

🌐 한국어

Build a product on an LLM and you will add a second provider. Because of pricing, because a particular model does something better, or because the first one had an outage. And that's the moment you discover that "send messages, get a reply" is the same everywhere while the way it's expressed is different everywhere.

Some take the system prompt as a top-level field; others want it inside the message array. Tool results are shaped differently. Streaming event names differ. Some signal end-of-stream explicitly and some don't. Let those differences leak into application code and every new provider adds another if provider == "...".

This article is about confining that variation to an adapter layer.

1. Make the Shared Interface Brutally Small

The most common mistake is a large interface. Keep saying "this provider supports X, let's add a method" and you end up with an interface where 3 of 20 implementations do something meaningful and the other 17 return nil.

Include only what every implementation can genuinely do. In practice, four methods.

type Provider interface {

    // Send messages, get a response

    Chat(ctx context.Context, req ChatRequest) (*ChatResponse, error)

    // Streaming — emit chunks via callback, return the assembled response at the end

    ChatStream(ctx context.Context, req ChatRequest, onChunk func(StreamChunk)) (*ChatResponse, error)

    DefaultModel() string

    Name() string

}

The important detail is that ChatStream streams via callback and still returns the final response. Callers need chunks for live output and simultaneously need the assembled result — tool calls, usage. Providing both from one method removes all the chunk-reassembly code from call sites.

2. Handle Capability Differences with Optional Interfaces

So what about extended features? In Go, optional interfaces are the answer. Define them separately and check with a type assertion where needed.

// Implemented only by providers supporting extended reasoning

type ThinkingCapable interface {

    SupportsThinking() bool

}

// Implemented only by providers holding per-session external state

// (long-lived child processes, dedicated connections)

type SessionCloser interface {

    CloseSession(ctx context.Context, sessionKey string) error

}

// Call site: use it if supported, move on quietly otherwise

if tc, ok := provider.(ThinkingCapable); ok && tc.SupportsThinking() {

    req.Options[OptThinkingLevel] = level

}

The benefit is that adding a new capability touches zero existing implementations. Adding a method to the main interface means editing 20 files; a new optional interface only needs implementing in the one place that supports it.

3. Declare Static Capabilities as a Struct

For capabilities you branch on at runtime, a declarative struct beats a method. Better to ask once and hold the value than to run ten type assertions to learn "does this stream?"

type ProviderCapabilities struct {

    Streaming        bool   // supports ChatStream?

    ToolCalling      bool   // accepts tool definitions?

    StreamWithTools  bool   // can stream while tool calls are in flight?

    Thinking         bool   // supports extended reasoning?

    Vision           bool   // accepts image inputs?

    CacheControl     bool   // supports block-level cache directives?

    MaxContextWindow int    // context window for the default model

    TokenizerID      string // which tokenizer to use for counting

}

type CapabilitiesAware interface {

    Capabilities() ProviderCapabilities

}

StreamWithTools sitting apart from Streaming is a scar from production. Implementations exist that stream fine until tool calls enter the mix and then emit broken chunks. A combination constraint like that can't be expressed by one boolean, so it gets its own field.

4. Separate Serialization from Transport

Inside each provider implementation, split one more layer. Converting between internal and provider formats and actually firing the HTTP request are different concerns.

// Handles only the translation between internal and provider-specific formats.

// Composed inside each Provider implementation (it does not replace Provider).

type ProviderAdapter interface {

    // internal request -> provider wire format (bytes + headers)

    ToRequest(req ChatRequest) ([]byte, http.Header, error)

    // provider response bytes -> internal response

    FromResponse(data []byte) (*ChatResponse, error)

    // one SSE chunk -> internal chunk. Returns nil for chunks to skip.

    FromStreamChunk(data []byte) (*StreamChunk, error)

    Capabilities() ProviderCapabilities

    Name() string

}

Split this way, the translation logic is testable without HTTP. Capture real response bytes from a provider into a file, feed them to FromResponse, and verify the parse. No network, no mock server. Most of the time you spend adding a provider goes into this translation code, so making it easy to test makes a large difference.

It also matters that FromStreamChunk can return nil. SSE streams carry keep-alives, metadata, and start/stop signals that contain no actual content. Surface those upward as "empty chunks" and every caller has to filter them. Swallowing them as nil in the adapter is correct.

5. Absorbing SSE Differences — What Actually Varies

Streaming is where providers diverge most. Both use SSE (Server-Sent Events), but the details differ.

  • OpenAI-style: only data: lines. Sends an explicit terminator, data: [DONE], at the end of the stream.
  • Anthropic-style: announces an event type first via event: lines (message_start, content_block_delta, and so on). There's no [DONE] marker; the stream ends when the connection closes.

The common mistake here is writing a fresh SSE parser per provider. You don't need to. One scanner handles both formats.

// Reads an SSE stream line by line and extracts data payloads.

// Tracks both event: types and data: payloads so several providers can share it.

func (s *SSEScanner) Next() bool {

    for s.scanner.Scan() {

        line := s.scanner.Text()

        // Track event type (used by Anthropic-style streams)

        if after, ok := strings.CutPrefix(line, "event:"); ok {

            s.eventType = strings.TrimSpace(after)

            continue

        }

        // Extract the data payload

        var payload string

        if after, ok := strings.CutPrefix(line, "data: "); ok {

            payload = after

        } else if after, ok := strings.CutPrefix(line, "data:"); ok {

            payload = after

        } else {

            continue // skip blank lines, comments, other fields

        }

        // [DONE] is the OpenAI-style terminator

        if payload == "[DONE]" {

            return false

        }

        s.data = payload

        return true

    }

    s.err = s.scanner.Err()

    return false

}

The shared scanner handles only the line-level protocol; interpreting the payload belongs to each adapter's FromStreamChunk. The concerns separate cleanly.

One production trap: raise the scanner buffer size. Go's bufio.Scanner defaults to a 64KB maximum line. Responses carrying images or long tool call arguments exceed it and the stream dies silently. That bug is extremely hard to track down.

sc := bufio.NewScanner(r)

sc.Buffer(make([]byte, 0, initBufSize), maxBufSize) // the 64KB default isn't enough

6. A Registry — Look Up by Name, Fall Back When Missing

With many implementations you need a lookup point. Adding hierarchical fallback there pays off in multi-tenant setups: use the provider a tenant registered with their own key if it exists, otherwise drop to the shared configuration.

type Registry struct {

    providers map[string]Provider // keyed "tenant/name"

    mu        sync.RWMutex

}

func (r *Registry) GetForTenant(tenantID uuid.UUID, name string) (Provider, error) {

    r.mu.RLock()

    defer r.mu.RUnlock()

    // 1) look for a tenant-specific registration first

    if tenantID != MasterTenantID {

        if p, ok := r.providers[key(tenantID, name)]; ok {

            return p, nil

        }

    }

    // 2) otherwise fall back to the shared default

    if p, ok := r.providers[key(MasterTenantID, name)]; ok {

        return p, nil

    }

    return nil, fmt.Errorf("provider not found: %s", name)

}

Don't forget to close the previous instance on replacement. Implementations holding HTTP clients, connection pools, or long-lived processes leak quietly otherwise.

func (r *Registry) Register(tenantID uuid.UUID, p Provider) {

    r.mu.Lock()

    defer r.mu.Unlock()

    k := key(tenantID, p.Name())

    if old, ok := r.providers[k]; ok {

        if c, ok := old.(io.Closer); ok {

            c.Close() // clean up before replacing — prevents resource leaks

        }

    }

    r.providers[k] = p

}

What You Actually Run Into Adding a New Provider

  • Tool schema dialects — the same JSON Schema is supported to different depths. Some reject oneOf, some require the top level to be an object, some 400 on specific keywords. A schema normalization layer eventually becomes necessary.
  • Tool call ID rules — some implementations return a 400 when IDs repeat across iterations of an agent loop. Mix in the iteration index to keep them unique.
  • Passing reasoning blocks back — models using extended reasoning sometimes require the reasoning blocks, with their signatures, returned verbatim on the next request. Reserve a field in your response struct to preserve that raw content.
  • Proxies claiming "compatible" APIs — they frequently 400 on unknown fields. Send optional fields only to native endpoints.

Summary

  • The shared interface holds only what every implementation truly does — four methods is enough
  • Extended features go in optional interfaces so new capabilities don't touch existing implementations
  • Expose runtime-branching capabilities as a declarative struct
  • Separating translation from transport makes parsing testable without HTTP
  • SSE should share line-level parsing and vary only in interpretation — and raise that buffer size
  • The registry gets hierarchical fallback plus cleanup on replacement

The point of the adapter pattern isn't elegance, it's isolating change. When one provider alters its API, do you edit one file or twenty? That difference is all that's really left.

댓글

이 블로그의 인기 게시물

한국투자증권 KIS API로 실시간 시세 받기 (WebSocket 실전)

파이썬으로 업비트 API 연동하기 — 시세 조회부터 주문까지 기초

Go로 자동매매 신호봇 프레임워크 설계하기