Skip to main content

Provider Architecture

The Go AI SDK uses a layered abstraction to decouple your application code from specific AI provider APIs. This guide explains the architecture, the interfaces involved, and how to build a custom provider or apply middleware.

Overview​

Your Application
│
▼
┌─────────────┐
│ ai package │ GenerateText, StreamText, GenerateImage, ...
└──────┬──────┘
│ uses
▼
┌──────────────────┐
│ provider.Provider │ LanguageModel(), ImageModel(), ...
└──────┬───────────┘
│ implemented by
▼
┌──────────────────────────────────────────────┐
│ providers/openai, providers/anthropic, ... │
│ (concrete provider implementations) │
└──────────────────────────────────────────────┘

The key insight: the ai package operates entirely against the provider interfaces — it never imports a concrete provider. This makes providers interchangeable and testable.

The Provider Interface​

provider.Provider is the entry point for every AI provider:

type Provider interface {
Name() string

LanguageModel(modelID string) (LanguageModel, error)
EmbeddingModel(modelID string) (EmbeddingModel, error)
ImageModel(modelID string) (ImageModel, error)
SpeechModel(modelID string) (SpeechModel, error)
TranscriptionModel(modelID string) (TranscriptionModel, error)
RerankingModel(modelID string) (RerankingModel, error)
}

Each method returns a model interface (or an error if the provider doesn't support that model type). Concrete providers like openai.Provider and anthropic.Provider implement this interface.

The LanguageModel Interface​

provider.LanguageModel is the core interface for text generation:

type LanguageModel interface {
// Metadata
SpecificationVersion() string // e.g., "v3"
Provider() string // e.g., "openai", "anthropic"
ModelID() string // e.g., "gpt-4o", "claude-sonnet-4-5"

// Capabilities
SupportsTools() bool
SupportsStructuredOutput() bool
SupportsImageInput() bool

// Generation
DoGenerate(ctx context.Context, opts *GenerateOptions) (*types.GenerateResult, error)
DoStream(ctx context.Context, opts *GenerateOptions) (TextStream, error)
}

The DoGenerate and DoStream methods accept a GenerateOptions struct that standardizes all generation parameters across providers. Provider-specific options are passed via ProviderOptions.

The ImageModel Interface​

provider.ImageModel covers image generation:

type ImageModel interface {
SpecificationVersion() string
Provider() string
ModelID() string

DoGenerate(ctx context.Context, opts *ImageGenerateOptions) (*types.ImageResult, error)
}

ProviderOptions: Passing Provider-Specific Settings​

When you need features unique to one provider (e.g., Anthropic prompt caching, OpenAI reasoning effort, Google image size), use ProviderOptions. The key is the provider name returned by Provider():

result, err := ai.GenerateText(ctx, ai.GenerateTextOptions{
Model: model,
Prompt: "Explain quantum computing.",
ProviderOptions: map[string]interface{}{
"anthropic": map[string]interface{}{
"thinking": map[string]interface{}{
"type": "enabled",
"budgetTokens": 8000,
},
},
},
})
// Google image size control
result, err := ai.GenerateImage(ctx, ai.GenerateImageOptions{
Model: imageModel,
Prompt: "A mountain landscape at dawn",
ProviderOptions: map[string]interface{}{
"google": map[string]interface{}{
"imageSize": google.ImageSize2K,
},
},
})
// Vertex AI — the ProviderOptions key is "googleVertex" (checked first),
// with "vertex" and then "google" honored as fallbacks for compatibility.
// "google-vertex" (with a hyphen) is the provider's identity string
// (Provider().Name(), used for model IDs like "google-vertex:gemini-..."),
// not a ProviderOptions key — it is never read here.
result, err := ai.GenerateText(ctx, ai.GenerateTextOptions{
Model: vertexModel,
Prompt: "Summarize this document.",
ProviderOptions: map[string]interface{}{
"googleVertex": map[string]interface{}{
"safetySettings": []map[string]interface{}{
{"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_ONLY_HIGH"},
},
},
},
})

The provider's DoGenerate method extracts its own key from ProviderOptions and ignores others — so the same call can carry options for multiple providers safely.

OpenAI-compatible providers use camelCase provider option keys, matching the TypeScript SDK public API. Deprecated kebab-case or snake_case keys are still accepted for migration compatibility, but they emit warnings so callers can move to camelCase.

result, err := ai.GenerateText(ctx, ai.GenerateTextOptions{
Model: groqModel,
Prompt: "Explain tool calling.",
ProviderOptions: map[string]interface{}{
"groq": map[string]interface{}{
"reasoningEffort": "high", // preferred
"parallelToolCalls": true,
},
},
})

Implementing a Custom Provider​

To add a new provider, implement the provider.Provider and provider.LanguageModel interfaces:

package myprovider

import (
"context"

"github.com/digitallysavvy/go-ai/pkg/provider"
"github.com/digitallysavvy/go-ai/pkg/provider/types"
)

// Provider implements provider.Provider for MyAI
type Provider struct {
apiKey string
baseURL string
}

// New creates a new MyAI provider
func New(apiKey string) *Provider {
return &Provider{
apiKey: apiKey,
baseURL: "https://api.myai.example/v1",
}
}

func (p *Provider) Name() string { return "myai" }

func (p *Provider) LanguageModel(modelID string) (provider.LanguageModel, error) {
if modelID == "" {
return nil, fmt.Errorf("model ID required")
}
return &LanguageModel{provider: p, modelID: modelID}, nil
}

// Implement remaining Provider methods (can return errors for unsupported types)
func (p *Provider) EmbeddingModel(modelID string) (provider.EmbeddingModel, error) {
return nil, fmt.Errorf("myai does not support embeddings")
}
func (p *Provider) ImageModel(modelID string) (provider.ImageModel, error) {
return nil, fmt.Errorf("myai does not support image generation")
}
func (p *Provider) SpeechModel(modelID string) (provider.SpeechModel, error) {
return nil, fmt.Errorf("myai does not support speech synthesis")
}
func (p *Provider) TranscriptionModel(modelID string) (provider.TranscriptionModel, error) {
return nil, fmt.Errorf("myai does not support transcription")
}
func (p *Provider) RerankingModel(modelID string) (provider.RerankingModel, error) {
return nil, fmt.Errorf("myai does not support reranking")
}

// LanguageModel wraps a MyAI model
type LanguageModel struct {
provider *Provider
modelID string
}

func (m *LanguageModel) SpecificationVersion() string { return "v3" }
func (m *LanguageModel) Provider() string { return "myai" }
func (m *LanguageModel) ModelID() string { return m.modelID }
func (m *LanguageModel) SupportsTools() bool { return true }
func (m *LanguageModel) SupportsStructuredOutput() bool { return true }
func (m *LanguageModel) SupportsImageInput() bool { return false }

// DoGenerate calls the MyAI API for text generation
func (m *LanguageModel) DoGenerate(ctx context.Context, opts *provider.GenerateOptions) (*types.GenerateResult, error) {
// Build request from opts
reqBody := buildMyAIRequest(opts)

// Call your API
resp, err := callMyAIAPI(ctx, m.provider.baseURL, m.provider.apiKey, m.modelID, reqBody)
if err != nil {
return nil, fmt.Errorf("myai generate: %w", err)
}

// Convert response to standard types.GenerateResult
return convertMyAIResponse(resp), nil
}

// DoStream calls the MyAI API for streaming text generation
func (m *LanguageModel) DoStream(ctx context.Context, opts *provider.GenerateOptions) (provider.TextStream, error) {
// Similar to DoGenerate but returns a provider.TextStream
// See existing providers for streaming patterns
return nil, fmt.Errorf("streaming not yet implemented")
}

Once implemented, your provider works anywhere the SDK accepts a provider.LanguageModel:

myProvider := myprovider.New(os.Getenv("MYAI_API_KEY"))
model, _ := myProvider.LanguageModel("my-model-v1")

result, err := ai.GenerateText(ctx, ai.GenerateTextOptions{
Model: model,
Prompt: "Hello from my custom provider!",
})

Middleware​

Language model middleware wraps a model to intercept and modify generation calls — without changing your application code.

package main

import (
"context"
"fmt"
"log"
"os"

"github.com/digitallysavvy/go-ai/pkg/ai"
"github.com/digitallysavvy/go-ai/pkg/middleware"
"github.com/digitallysavvy/go-ai/pkg/provider"
"github.com/digitallysavvy/go-ai/pkg/provider/types"
"github.com/digitallysavvy/go-ai/pkg/providers/openai"
)

// loggingMiddleware logs every generation request
var loggingMiddleware = &middleware.LanguageModelMiddleware{
WrapGenerate: func(
ctx context.Context,
doGenerate func() (*types.GenerateResult, error),
doStream func() (provider.TextStream, error),
params *provider.GenerateOptions,
model provider.LanguageModel,
) (*types.GenerateResult, error) {
log.Printf("generating with prompt: %q", params.Prompt.Text)
result, err := doGenerate()
if err == nil {
log.Printf("generated %d tokens", result.Usage.GetTotalTokens())
}
return result, err
},
}

func main() {
ctx := context.Background()

p := openai.New(openai.Config{APIKey: os.Getenv("OPENAI_API_KEY")})
base, _ := p.LanguageModel("gpt-4o")

// Wrap the model with middleware
wrapped := middleware.WrapLanguageModel(base, []*middleware.LanguageModelMiddleware{loggingMiddleware}, nil, nil)

result, _ := ai.GenerateText(ctx, ai.GenerateTextOptions{
Model: wrapped,
Prompt: "What is the capital of France?",
})
fmt.Println(result.Text)
}

Middleware Execution Order​

When multiple middlewares are applied, they execute from first to last on the way in, and last to first on the way out (like standard middleware chains):

wrapped := middleware.WrapLanguageModel(
base,
[]*middleware.LanguageModelMiddleware{
rateLimitMiddleware, // executes first
cachingMiddleware, // executes second
loggingMiddleware, // executes third
},
nil, nil,
)
// Call order: rateLimitMiddleware → cachingMiddleware → loggingMiddleware → base

The SDK ships several built-in middlewares:

MiddlewarePackagePurpose
Rate limitingmiddlewareThrottle requests per second
CachingmiddlewareCache identical prompts
RetrymiddlewareAutomatic retries with backoff
LoggingmiddlewareRequest/response logging
TelemetrymiddlewareOpenTelemetry tracing

See the Language Model Middleware guide for full details on built-in and custom middlewares.

Testing with Mock Providers​

To test code that uses the SDK without hitting real APIs, implement a minimal provider.LanguageModel that returns controlled responses:

type MockLanguageModel struct {
responses []string
idx int
}

func (m *MockLanguageModel) SpecificationVersion() string { return "v3" }
func (m *MockLanguageModel) Provider() string { return "mock" }
func (m *MockLanguageModel) ModelID() string { return "mock-model" }
func (m *MockLanguageModel) SupportsTools() bool { return true }
func (m *MockLanguageModel) SupportsStructuredOutput() bool { return true }
func (m *MockLanguageModel) SupportsImageInput() bool { return false }

func (m *MockLanguageModel) DoGenerate(ctx context.Context, opts *provider.GenerateOptions) (*types.GenerateResult, error) {
text := m.responses[m.idx%len(m.responses)]
m.idx++
return &types.GenerateResult{Text: text}, nil
}

func (m *MockLanguageModel) DoStream(ctx context.Context, opts *provider.GenerateOptions) (provider.TextStream, error) {
return nil, fmt.Errorf("streaming not supported in mock")
}

// In tests:
func TestMyFeature(t *testing.T) {
model := &MockLanguageModel{
responses: []string{"Hello from mock!", "Another response"},
}

result, err := ai.GenerateText(context.Background(), ai.GenerateTextOptions{
Model: model,
Prompt: "Say hello",
})
require.NoError(t, err)
assert.Equal(t, "Hello from mock!", result.Text)
}

See Also​