AI Provider Multi-Model Configuration

Computation Graphs & ML Infrastructure intermediate Building with AI ~35 min read

Overview

Modern AI-powered applications rarely rely on a single model provider. Production systems need to integrate with OpenAI for GPT models, Anthropic for Claude, Google for Gemini, and local inference engines like Ollama—often simultaneously. Multi-provider AI configuration encompasses the architecture patterns, credential management, and runtime selection logic that allow an application to seamlessly switch between providers, models, and deployment targets without code changes.

The challenge goes beyond simple HTTP client wrappers. Each provider has a different API shape, authentication mechanism, token counting approach, rate limiting behavior, and streaming protocol. A well-designed multi-provider system abstracts these differences behind a unified interface while preserving provider-specific capabilities like function calling, vision inputs, or structured output modes. API keys must be stored securely—encrypted at rest—and the system must handle provider failures gracefully with fallback chains.

In a typical multi-provider AI application, users configure their preferred providers through a settings interface. API keys are encrypted before storage using AES-GCM encryption, and the provider selection logic routes requests to the appropriate backend based on model availability, cost, and user preference. This pattern enables the application to work with whichever AI services the user has access to, from enterprise OpenAI deployments to fully local Ollama instances.

Core Concepts

Provider Abstraction Layer

The provider abstraction defines a common interface that all AI backends must implement. This typically includes methods for text completion, chat completion, embedding generation, and streaming responses. Each provider adapter translates between the unified interface and the provider-specific API format, handling differences in request/response schemas, error codes, and authentication headers.

Key Concept: Provider Interface

A provider interface decouples application logic from specific AI services. The application code calls provider.Complete(prompt) without knowing whether the request goes to OpenAI, Anthropic, or a local model. This enables runtime provider switching, A/B testing between models, and graceful fallback when a provider is unavailable.

API Key Encryption

AI provider API keys are high-value secrets—a leaked OpenAI key can generate thousands of dollars in charges within minutes. Multi-provider systems must encrypt keys at rest using authenticated encryption (AES-256-GCM), derive encryption keys from user credentials or hardware-bound secrets, and never log or transmit keys in plaintext. The encryption boundary should be as tight as possible: decrypt only when making an API call, and clear the plaintext from memory immediately after.

Model Registry and Capabilities

Each provider offers multiple models with different capabilities, context windows, pricing tiers, and feature sets. A model registry maps model identifiers to their capabilities: maximum token count, support for vision/images, function calling, JSON mode, and streaming. This registry drives the UI (showing only compatible models for a given task) and the runtime (rejecting requests that exceed a model’s capabilities before making an API call).

Key Concept: Capability-Based Model Selection

Instead of hardcoding model names, select models by required capabilities: “I need a model that supports function calling with at least 32K context.” The registry returns the best available model matching those constraints, automatically adapting as providers release new models or deprecate old ones.

Fallback Chains

Provider outages are common—OpenAI rate limits, Anthropic has capacity issues, local models run out of GPU memory. A fallback chain defines an ordered list of providers to try for a given request. If the primary provider fails (timeout, rate limit, 5xx error), the system automatically retries with the next provider in the chain. Fallback decisions must account for latency budgets, cost differences, and capability compatibility.

How It Works

Provider Interface Definition

The core abstraction defines what every AI provider must support:

// Provider defines the interface all AI backends must implement.
type Provider interface {
    // Name returns the provider identifier (e.g., "openai", "anthropic", "ollama").
    Name() string

    // Models returns the list of available models for this provider.
    Models() []ModelInfo

    // Complete sends a completion request and returns the full response.
    Complete(ctx context.Context, req *CompletionRequest) (*CompletionResponse, error)

    // Stream sends a completion request and returns a stream of response chunks.
    Stream(ctx context.Context, req *CompletionRequest) (ResponseStream, error)

    // Embed generates embeddings for the given texts.
    Embed(ctx context.Context, texts []string, model string) ([][]float32, error)
}

type ModelInfo struct {
    ID           string   `json:"id"`
    Name         string   `json:"name"`
    Provider     string   `json:"provider"`
    MaxTokens    int      `json:"max_tokens"`
    ContextWindow int     `json:"context_window"`
    Capabilities []string `json:"capabilities"` // "chat", "vision", "function_calling", "json_mode"
    CostPer1KIn  float64  `json:"cost_per_1k_input"`
    CostPer1KOut float64  `json:"cost_per_1k_output"`
}

type CompletionRequest struct {
    Model       string            `json:"model"`
    Messages    []Message         `json:"messages"`
    Temperature float64           `json:"temperature,omitempty"`
    MaxTokens   int               `json:"max_tokens,omitempty"`
    Tools       []ToolDefinition  `json:"tools,omitempty"`
    Stream      bool              `json:"stream,omitempty"`
}

type CompletionResponse struct {
    Content    string      `json:"content"`
    Model      string      `json:"model"`
    Usage      UsageStats  `json:"usage"`
    ToolCalls  []ToolCall  `json:"tool_calls,omitempty"`
    FinishReason string    `json:"finish_reason"`
}

API Key Encryption with AES-GCM

Keys are encrypted before being persisted to configuration files or databases:

import (
    "crypto/aes"
    "crypto/cipher"
    "crypto/rand"
    "crypto/sha256"
    "encoding/base64"
    "fmt"
    "io"
)

// KeyEncryptor handles encryption and decryption of API keys.
type KeyEncryptor struct {
    gcm cipher.AEAD
}

// NewKeyEncryptor creates an encryptor from a passphrase.
// The passphrase is hashed with SHA-256 to produce a 256-bit AES key.
func NewKeyEncryptor(passphrase string) (*KeyEncryptor, error) {
    hash := sha256.Sum256([]byte(passphrase))
    block, err := aes.NewCipher(hash[:])
    if err != nil {
        return nil, fmt.Errorf("creating cipher: %w", err)
    }

    gcm, err := cipher.NewGCM(block)
    if err != nil {
        return nil, fmt.Errorf("creating GCM: %w", err)
    }

    return &KeyEncryptor{gcm: gcm}, nil
}

// Encrypt encrypts an API key, returning a base64-encoded ciphertext.
func (e *KeyEncryptor) Encrypt(apiKey string) (string, error) {
    nonce := make([]byte, e.gcm.NonceSize())
    if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
        return "", fmt.Errorf("generating nonce: %w", err)
    }

    ciphertext := e.gcm.Seal(nonce, nonce, []byte(apiKey), nil)
    return base64.StdEncoding.EncodeToString(ciphertext), nil
}

// Decrypt decrypts a base64-encoded ciphertext back to the original API key.
func (e *KeyEncryptor) Decrypt(encrypted string) (string, error) {
    data, err := base64.StdEncoding.DecodeString(encrypted)
    if err != nil {
        return "", fmt.Errorf("decoding base64: %w", err)
    }

    nonceSize := e.gcm.NonceSize()
    if len(data) < nonceSize {
        return "", fmt.Errorf("ciphertext too short")
    }

    nonce, ciphertext := data[:nonceSize], data[nonceSize:]
    plaintext, err := e.gcm.Open(nil, nonce, ciphertext, nil)
    if err != nil {
        return "", fmt.Errorf("decryption failed: %w", err)
    }

    return string(plaintext), nil
}
Key Concept: Authenticated Encryption

AES-GCM provides both confidentiality and integrity. The authentication tag ensures that tampered ciphertext is rejected during decryption, preventing an attacker from modifying encrypted keys without detection. Always use authenticated encryption modes (GCM, ChaCha20-Poly1305) rather than raw AES-CBC or AES-CTR, which provide confidentiality but not integrity.

Provider Registry and Factory

A registry maps provider names to their constructors, enabling runtime instantiation:

type ProviderConfig struct {
    Name        string `json:"name"`
    APIKey      string `json:"api_key"`       // encrypted
    BaseURL     string `json:"base_url"`      // custom endpoints (Azure, proxies)
    OrgID       string `json:"org_id"`        // optional org/project scoping
    MaxRetries  int    `json:"max_retries"`
    TimeoutSecs int    `json:"timeout_secs"`
}

type ProviderFactory func(cfg ProviderConfig, enc *KeyEncryptor) (Provider, error)

type ProviderRegistry struct {
    mu        sync.RWMutex
    factories map[string]ProviderFactory
    instances map[string]Provider
    encryptor *KeyEncryptor
}

func NewProviderRegistry(enc *KeyEncryptor) *ProviderRegistry {
    r := &ProviderRegistry{
        factories: make(map[string]ProviderFactory),
        instances: make(map[string]Provider),
        encryptor: enc,
    }
    // Register built-in providers
    r.Register("openai", NewOpenAIProvider)
    r.Register("anthropic", NewAnthropicProvider)
    r.Register("gemini", NewGeminiProvider)
    r.Register("ollama", NewOllamaProvider)
    return r
}

func (r *ProviderRegistry) Register(name string, factory ProviderFactory) {
    r.mu.Lock()
    defer r.mu.Unlock()
    r.factories[name] = factory
}

func (r *ProviderRegistry) Get(cfg ProviderConfig) (Provider, error) {
    r.mu.RLock()
    if p, ok := r.instances[cfg.Name]; ok {
        r.mu.RUnlock()
        return p, nil
    }
    r.mu.RUnlock()

    r.mu.Lock()
    defer r.mu.Unlock()

    // Double-check after acquiring write lock
    if p, ok := r.instances[cfg.Name]; ok {
        return p, nil
    }

    factory, ok := r.factories[cfg.Name]
    if !ok {
        return nil, fmt.Errorf("unknown provider: %s", cfg.Name)
    }

    p, err := factory(cfg, r.encryptor)
    if err != nil {
        return nil, fmt.Errorf("initializing provider %s: %w", cfg.Name, err)
    }

    r.instances[cfg.Name] = p
    return p, nil
}

Practical Applications

User-Configurable AI Settings

In the platform, users configure their AI providers through a settings panel. Each provider entry includes the API key (encrypted client-side before storage), preferred model, and optional custom endpoint URL. The settings UI queries each provider’s model list to populate dropdowns, only showing models the user’s API key has access to. This approach lets enterprise users point to Azure OpenAI endpoints while personal users connect directly to OpenAI’s API.

Cost-Aware Model Routing

Different tasks have different quality and cost requirements. A summarization task might use a cheaper model (GPT-4o-mini, Haiku) while a complex reasoning task routes to a more capable model (GPT-4o, Opus). The multi-provider system enables cost-aware routing: attach a cost budget to each request, and the router selects the cheapest model that meets the capability requirements. Usage tracking aggregates costs across providers to enforce organizational spending limits.

Hybrid Local/Cloud Inference

Some workloads benefit from a hybrid approach: use local Ollama models for high-volume, latency-insensitive tasks (bulk document summarization, classification) and cloud providers for low-volume, quality-critical tasks (user-facing chat, complex analysis). The multi-provider architecture makes this transparent—the routing layer selects local or cloud based on the request parameters without the calling code needing to know the difference.

Provider Health Monitoring

Track latency, error rates, and availability per provider. When a provider’s error rate exceeds a threshold, automatically route traffic to healthy alternatives. Expose provider health metrics to operators via dashboards, enabling proactive capacity planning and vendor negotiations based on real reliability data.

Implementation Patterns

Fallback Chain with Circuit Breaker

Combining fallback chains with circuit breakers prevents cascading failures:

type FallbackRouter struct {
    providers []Provider
    breakers  map[string]*CircuitBreaker
}

type CircuitBreaker struct {
    mu          sync.Mutex
    failures    int
    threshold   int
    resetAfter  time.Duration
    lastFailure time.Time
    state       string // "closed", "open", "half-open"
}

func (cb *CircuitBreaker) Allow() bool {
    cb.mu.Lock()
    defer cb.mu.Unlock()

    switch cb.state {
    case "closed":
        return true
    case "open":
        if time.Since(cb.lastFailure) > cb.resetAfter {
            cb.state = "half-open"
            return true
        }
        return false
    case "half-open":
        return true
    }
    return false
}

func (cb *CircuitBreaker) RecordSuccess() {
    cb.mu.Lock()
    defer cb.mu.Unlock()
    cb.failures = 0
    cb.state = "closed"
}

func (cb *CircuitBreaker) RecordFailure() {
    cb.mu.Lock()
    defer cb.mu.Unlock()
    cb.failures++
    cb.lastFailure = time.Now()
    if cb.failures >= cb.threshold {
        cb.state = "open"
    }
}

func (r *FallbackRouter) Complete(ctx context.Context, req *CompletionRequest) (*CompletionResponse, error) {
    var lastErr error
    for _, p := range r.providers {
        cb := r.breakers[p.Name()]
        if !cb.Allow() {
            continue // skip providers with open circuit breakers
        }

        resp, err := p.Complete(ctx, req)
        if err == nil {
            cb.RecordSuccess()
            return resp, nil
        }

        cb.RecordFailure()
        lastErr = fmt.Errorf("provider %s: %w", p.Name(), err)
    }
    return nil, fmt.Errorf("all providers failed, last error: %w", lastErr)
}

Configuration Persistence

Provider configurations are serialized to JSON with encrypted API keys:

type AISettings struct {
    Providers     []ProviderConfig `json:"providers"`
    DefaultModel  string           `json:"default_model"`
    FallbackOrder []string         `json:"fallback_order"`
    MaxCostPerDay float64          `json:"max_cost_per_day"`
}

func SaveSettings(path string, settings *AISettings, enc *KeyEncryptor) error {
    // Encrypt all API keys before serialization
    for i := range settings.Providers {
        if settings.Providers[i].APIKey != "" &&
           !strings.HasPrefix(settings.Providers[i].APIKey, "enc:") {
            encrypted, err := enc.Encrypt(settings.Providers[i].APIKey)
            if err != nil {
                return fmt.Errorf("encrypting key for %s: %w",
                    settings.Providers[i].Name, err)
            }
            settings.Providers[i].APIKey = "enc:" + encrypted
        }
    }

    data, err := json.MarshalIndent(settings, "", "  ")
    if err != nil {
        return fmt.Errorf("marshaling settings: %w", err)
    }

    return os.WriteFile(path, data, 0600) // restrictive permissions
}
Key Concept: Defense in Depth for Secrets

Encrypt API keys at the application layer (AES-GCM), write config files with restrictive permissions (0600), and use OS-level secret storage (macOS Keychain, Windows Credential Manager, Linux Secret Service) when available. No single layer is sufficient—encryption protects against file theft, permissions protect against local privilege escalation, and OS secret stores protect against application-level vulnerabilities.

Performance Considerations

Common Pitfalls

Further Reading