n4nAI

Structuring a Go package for multi-provider LLM clients

Practical guide to designing a Go package structure for LLM clients that span multiple providers, with interfaces, adapters, and fallback.

n4n Team3 min read707 words

Audio narration

Coming soon — every post will get a voice note here.

Building a multi-provider integration in Go starts with boundaries. A clean go package structure llm client separates the provider-agnostic API from the messy HTTP details of each backend, so you can add Anthropic or Mistral without rewriting call sites. This guide walks through an ordered path from interface definition to on-disk layout, with the tradeoffs we hit shipping similar clients.

1. Define a provider-agnostic core interface

Start by writing the smallest interface that every backend must satisfy. Keep it free of any vendor-specific fields. If you leak temperature or top_p into the root type, you will regret it when a provider uses a different parameter shape.

package llm

import "context"

type Message struct {
	Role    string // "system", "user", "assistant"
	Content string
}

type Usage struct {
	PromptTokens     int
	CompletionTokens int
}

type ChatRequest struct {
	Model    string
	Messages []Message
}

type Response struct {
	ID      string
	Model   string
	Message Message
	Usage   Usage
}

type Client interface {
	Chat(ctx context.Context, req ChatRequest) (*Response, error)
}

This core gives call sites a stable target. Adapters map their wire format to these structs. The interface is deliberately narrow; streaming and advanced options live elsewhere.

2. Isolate provider specifics in adapter packages

The go package structure llm client we advocate puts each provider in its own subpackage under the root. The openai package implements llm.Client; the anthropic package does the same. Neither imports the other.

package openai

import (
	"context"
	"net/http"

	"yourmodule/llm"
)

type Client struct {
	baseURL string
	apiKey  string
	http    *http.Client
}

func New(baseURL, apiKey string, h *http.Client) *Client {
	return &Client{baseURL: baseURL, apiKey: apiKey, http: h}
}

func (c *Client) Chat(ctx context.Context, req llm.ChatRequest) (*llm.Response, error) {
	// translate req to OpenAI's JSON, POST, decode, translate back
}

Tradeoff: you duplicate the request-mapping logic per provider. That is cheaper than a tangled union type that breaks when either vendor changes. Keep the translation pure where possible—no context or http inside the mapper functions.

3. Build a routing and fallback layer

A multi-provider system needs a component that decides which adapter handles a call. Implement the same llm.Client interface so the router is transparent to business code.

package router

type Router struct {
	primary   llm.Client
	fallback  llm.Client
}

func (r *Router) Chat(ctx context.Context, req llm.ChatRequest) (*llm.Response, error) {
	resp, err := r.primary.Chat(ctx, req)
	if err != nil {
		return r.fallback.Chat(ctx, req)
	}
	return resp, nil
}

Error classification matters: only fall back on 429/5xx, not on a malformed prompt. If you would rather not operate the fallback logic yourself, a gateway such as n4n.ai exposes a single OpenAI-compatible endpoint across 240+ models with automatic fallback when a provider is degraded, but self-hosted systems still need this pattern for control over routing policy.

4. Configure transport with net/http explicitly

Never use http.DefaultClient in a long-lived service. Set timeouts and connection pooling once, then inject the client into every adapter.

transport := &http.Transport{
	MaxIdleConns:        100,
	MaxIdleConnsPerHost: 10,
	IdleConnTimeout:     90 * time.Second,
}
httpClient := &http.Client{
	Timeout:   30 * time.Second,
	Transport: transport,
}

A missing Timeout lets a stalled TCP connection block a goroutine indefinitely. Connection reuse cuts TLS handshake cost on high-throughput batch jobs.

5. Handle streaming as a separate path

Streaming responses do not fit a (*Response, error) signature. Add a distinct interface rather than overloading Chat.

type StreamChunk struct {
	Delta string
	Done  bool
}

type StreamingClient interface {
	StreamChat(ctx context.Context, req ChatRequest) (<-chan StreamChunk, error)
}

Adapters implement StreamingClient only if the provider supports SSE. Call sites that need tokens incrementally use the stream; batch callers stay on Chat. The tradeoff is two code paths per provider, but the alternative—returning io.ReadCloser from Chat—forces non-streaming users to drain a body they did not ask for.

6. Compose request options functionally

Advanced parameters vary wildly. Use the functional options pattern at the adapter level, not in the core ChatRequest.

type Option func(*requestOpts)

type requestOpts struct {
	temperature float32
	maxTokens   int
}

func WithTemperature(t float32) Option {
	return func(o *requestOpts) { o.temperature = t }
}

func (c *Client) ChatWithOpts(ctx context.Context, req llm.ChatRequest, opts ...Option) (*llm.Response, error) {
	o := &requestOpts{temperature: 0.7, maxTokens: 512}
	for _, opt := range opts {
		opt(o)
	}
	// merge o into provider payload
}

This keeps the root package stable while letting each backend expose its quirks. Avoid putting Option in llm.Client; it would infect every adapter with the union of all providers’ knobs.

7. Lay out the package on disk

A concrete tree prevents “where does this go” debates:

llm/
  client.go        # interfaces and core types
  types.go         # Message, Usage, etc.
  router/
    router.go      # primary/fallback orchestration
  openai/
    client.go
    mapper.go      # req/resp translation
  anthropic/
    client.go
    mapper.go
  internal/
    httpx/
      client.go    # shared transport helpers

This go package structure llm client keeps the root package small and makes the dependency direction obvious: adapters depend on llm, never on each other. internal/httpx holds retry and auth header logic reused across adapters.

8. Test against recorded fixtures

Use httptest to simulate provider APIs. Record real responses once, replay them in unit tests.

func TestOpenAIAdapter(t *testing.T) {
	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(200)
		_ = json.NewEncoder(w).Encode(fixtureOpenAIResponse)
	}))
	defer ts.Close()

	c := openai.New(ts.URL, "test-key", http.DefaultClient)
	resp, err := c.Chat(context.Background(), llm.ChatRequest{Model: "gpt-4o"})
	if err != nil {
		t.Fatal(err)
	}
	if resp.Usage.CompletionTokens == 0 {
		t.Fatalf("expected token accounting")
	}
}

Test the router’s fallback by making the primary server return 500. Engineers often skip this and discover fallback bugs in production when a provider actually degrades.

9. Common pitfalls and tradeoffs

  • Context propagation: pass ctx into every HTTP call and honor cancellation. Adapters that ignore ctx will leak goroutines during client timeouts.
  • Token metering: normalize usage into llm.Usage even if a provider returns a different schema. Downstream billing code should not know provider names.
  • Version drift: provider APIs change weekly. Isolate the JSON tags in mapper.go so a field rename touches one file.
  • Over-abstraction: do not build a generic “model capability” matrix in the core. Let the router take plain llm.Client instances; capability filtering belongs in config.
  • Sync vs async: blocking Chat is correct for most RPC-style calls. Forcing async everywhere adds goroutine bookkeeping with no benefit.

A disciplined go package structure llm client treats providers as pluggable adapters behind a tiny interface, puts transport configuration in one place, and reserves streaming and options for explicit secondary paths. That layout survives adding the tenth provider without a rewrite.

Tagsgolangarchitecturesdkllm-api

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All go net/http llm api client posts →