Implement a multi-provider AI abstraction layer with a factory function, unified provider interface, and pluggable backends. Use when the user asks to "add AI provider abstraction", "support multiple AI models", "multi-provider AI", "LLM provider factory", "swap AI providers", "AI provider interface", "OpenRouter Ollama integration", "abstract LLM providers", or wants to support cloud and local AI inference behind a single API.
From recipesnpx claudepluginhub ichabodcole/project-docs-scaffold-template --plugin recipesThis skill uses the workspace's default tool permissions.
Provides UI/UX resources: 50+ styles, color palettes, font pairings, guidelines, charts for web/mobile across React, Next.js, Vue, Svelte, Tailwind, React Native, Flutter. Aids planning, building, reviewing interfaces.
Fetches up-to-date documentation from Context7 for libraries and frameworks like React, Next.js, Prisma. Use for setup questions, API references, and code examples.
Calculates TAM/SAM/SOM using top-down, bottom-up, and value theory methodologies for market sizing, revenue estimation, and startup validation.
Implement a provider abstraction layer that lets an application use multiple AI/LLM backends (cloud APIs, local inference servers, self-hosted models) through a single unified interface. A factory function creates the correct provider instance based on a provider ID and configuration, so the rest of the application never needs to know which backend is active.
This recipe is technology-agnostic at the architecture level. The interface design, factory pattern, and configuration model work with any language, framework, or AI SDK. The concepts apply whether you are building a desktop app, web app, mobile app, or server-side service.
Every AI provider implements the same abstract interface. Application code never instantiates providers directly -- it calls a factory function with a provider ID and configuration, and gets back an object that satisfies the interface.
Application Code
│
▼
┌─────────────────────┐
│ Factory Function │ createProvider(providerId, config) → Provider
│ (switch on ID) │
└──────┬──────┬───────┘
│ │
▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Cloud │ │ Local │ │ Custom │
│ Provider │ │ Provider │ │ Provider │
│(API key) │ │(base URL)│ │ (...) │
└──────────┘ └──────────┘ └──────────┘
│ │ │
▼ ▼ ▼
OpenRouter Ollama Your own
OpenAI LM Studio endpoint
Providers fall into two categories with different configuration requirements:
| Category | Auth | Discovery | Examples |
|---|---|---|---|
| Cloud | API key | HTTP to vendor | OpenRouter, OpenAI, Anthropic |
| Local | None / URL | HTTP to localhost | Ollama, LM Studio, vLLM |
This distinction matters for configuration, validation, and UI. Cloud providers need API key management and validation. Local providers need server URL configuration and connectivity checks.
Problem it solves: Application logic (chat, content transformation, document operations) should not care which AI backend is active. Users should be able to switch providers without touching business logic. Adding a new provider should require implementing one class and registering it in the factory.
What it avoids:
Trade-offs:
The abstract interface defines the contract every provider must satisfy. This is the single most important artifact in the system -- everything else flows from it.
ProviderService (abstract)
├── testConnection() → ConnectionResult
├── getAvailableModels() → Model[]
├── generateContent(options) → string
├── validateConfig() → boolean
└── getProviderInfo() → ProviderInfo
testConnection() → ConnectionResultValidates that the provider is reachable and credentials are correct. For cloud providers, this typically sends a minimal request (e.g., a cheap completion with a test prompt). For local providers, this pings the server and lists models.
Return type:
ConnectionResult {
success: boolean
error?: string
responseTime?: number // milliseconds
modelTested?: string // which model was used for the test
timestamp: string // ISO timestamp
}
Why this exists: Users configure API keys and server URLs in settings. Without an explicit test step, the first error they see is a cryptic failure during actual use. Test-before-use is a much better UX.
getAvailableModels() → Model[]Returns the list of models available from this provider. Cloud providers call their model listing API. Local providers query the running server.
Return type:
Model {
id: string // Unique model identifier (e.g., "openai/gpt-4o-mini")
name: string // Human-readable name
description?: string // Optional description or capability summary
context_length?: number // Max context window in tokens
provider: ProviderID // Which provider this model belongs to
raw?: any // Provider-specific raw data (pricing, architecture, etc.)
}
Why raw exists: Different providers return wildly different metadata
(OpenRouter includes pricing tiers; Ollama includes quantization levels). Rather
than trying to normalize everything, keep the raw response available for
provider-specific UI.
generateContent(options) → stringThe core generation method. Sends a chat completion request and returns the response text.
Parameters:
{
model: string // Model ID to use
messages: Message[] // Chat messages array
maxTokens?: number // Max tokens in response
temperature?: number // Sampling temperature (0.0 - 1.0)
}
Message {
role: 'system' | 'user' | 'assistant'
content: string
}
Important design decision: This returns a plain string, not a stream. If
your application needs streaming, add a separate streamContent() method that
returns an async iterator or readable stream. Do not overload generateContent
with both behaviors -- the calling code and error handling are fundamentally
different.
validateConfig() → booleanSynchronous check that the provider's configuration is structurally valid (API key present and correctly formatted, base URL is a valid HTTP URL, etc.). This does NOT make network calls -- it checks configuration shape only.
Examples:
sk-or- for OpenRouter)http:// or https://getProviderInfo() → ProviderInfoReturns static metadata about the provider.
ProviderInfo {
id: ProviderID // Enum value identifying this provider
name: string // Display name ("OpenRouter", "Ollama")
requiresApiKey: boolean // Whether this provider needs an API key
requiresUrl: boolean // Whether this provider needs a base URL
}
The abstract base class should also provide shared helper methods that concrete providers inherit:
makeRequest(url, options) → { success, data?, error? } - HTTP wrapper
with error handling and JSON parsing. Saves each provider from reimplementing
fetch-and-parse.withTiming(operation) → { result, responseTime } - Wraps an async
operation and measures wall-clock time. Used by testConnection to report
response times.The factory is intentionally simple -- a switch statement on the provider ID. No dependency injection containers, no plugin registries, no dynamic loading. A switch statement is easy to read, easy to test, and the number of providers is small enough that scaling is not a concern.
function createProviderService(providerId, config) → ProviderService:
switch (providerId):
case 'openrouter':
VALIDATE: config must include apiKey (throw if missing)
return new OpenRouterService(config)
case 'ollama':
return new OllamaService(config) // baseUrl has a default
case 'lmstudio':
return new LMStudioService(config) // baseUrl has a default
default:
throw "Unknown AI provider: {providerId}"
Validate at creation time, not at call time. If a cloud provider requires an API key, the factory throws immediately when the key is missing -- not when the first API call fails five minutes later. This is a fail-fast pattern.
Local providers have sensible defaults. Ollama defaults to
http://localhost:11434, LM Studio to http://localhost:1234. Users only need
to change these if they are running non-standard configurations.
The factory takes a flat config object. { apiKey?, baseUrl? } -- not
nested per-provider config. The factory knows which fields to extract. This
keeps the calling code simple: it does not need to know the shape of each
provider's config.
Each provider stores its own configuration in a typed settings object. The top-level settings structure tracks which provider is currently selected.
AISettings {
selectedProvider: ProviderID // Currently active provider
cloudProvider: CloudProviderSettings {
providerID: ProviderID
apiKey: string
isApiKeyValid: boolean // Cached validation state
selectedModel: string // Model ID for this provider
lastValidatedAt?: string // ISO timestamp of last successful test
}
localProviderA: LocalProviderSettings {
providerID: ProviderID
serverUrl: string // e.g., "http://localhost:11434"
isConnectionValid: boolean // Cached connection state
selectedModel: string // Model ID for this provider
lastValidatedAt?: string
}
localProviderB: LocalProviderSettings { ... }
preferences: {
autoLoadModels: boolean // Load models on startup
requestTimeout: number // Seconds before timing out
}
}
Use discriminated types and type guards to safely narrow provider settings:
CloudProviderSettings has: apiKey, isApiKeyValid
LocalProviderSettings has: serverUrl, isConnectionValid
isCloudProvider(settings) → settings is CloudProviderSettings
check: 'apiKey' in settings
isLocalProvider(settings) → settings is LocalProviderSettings
check: 'serverUrl' in settings
This lets shared code (like the configuration builder) handle both categories without provider-specific branching:
if isLocalProvider(settings):
config.baseUrl = settings.serverUrl
else if isCloudProvider(settings):
config.apiKey = settings.apiKey
A static array defines the available providers with their metadata. This drives the settings UI without hardcoding provider details in components:
AVAILABLE_PROVIDERS = [
{ id: 'openrouter', name: 'OpenRouter', type: 'cloud', requiresApiKey: true, requiresUrl: false, description: '...' },
{ id: 'ollama', name: 'Ollama', type: 'local', requiresApiKey: false, requiresUrl: true, description: '...' },
{ id: 'lmstudio', name: 'LM Studio', type: 'local', requiresApiKey: false, requiresUrl: true, description: '...' },
]
Why a static array instead of deriving from the factory? The registry includes UI metadata (descriptions, enabled flags) that the factory does not need. Keeping them separate avoids coupling UI concerns to the service layer.
Define the abstract interface and base class with shared utilities.
Model, ConnectionResult, and ProviderInfo typesmakeRequest and withTiming helpersCloudProviderSettings, LocalProviderSettings,
AISettings)isCloudProvider, isLocalProvider)Validate: Types compile. Base class can be instantiated by a trivial subclass.
Implement a cloud provider (e.g., OpenRouter or OpenAI) as the reference implementation.
testConnection -- send a cheap completion, check for valid
responsegetAvailableModels -- call the models API, normalize to Model[]generateContent -- send chat completion, extract response textvalidateConfig -- check API key formatgetProviderInfo -- return static metadataValidate: Can test connection, list models, and generate content with a valid API key. Invalid key produces a clear error message.
Implement a local provider (e.g., Ollama or LM Studio).
testConnection -- ping the server, list available modelsgetAvailableModels -- query the local server's model listgenerateContent -- send chat request to local endpointvalidateConfig -- check that base URL is a valid HTTP URLSecurity note for desktop/Electron apps: Local provider HTTP calls should go through a privileged process (main process / IPC bridge), not directly from the renderer. The renderer service calls an IPC channel, and the main process makes the actual HTTP request. This prevents renderer-process network access and enables URL validation on the trusted side.
Validate: Can connect to a running local server, list its models, and generate content.
Validate: All factory tests pass. Unknown providers produce clear errors.
Wire the factory into the application's state management layer.
AISettingsloadSettings -- reads persisted settings, deep-merges with
defaultssaveSettings -- persists current settingsloadProviderModels(providerId) -- creates a service via the
factory, calls getAvailableModels, stores the resulttestConnection(providerId) -- creates a service via the factory,
calls testConnection, updates validation stateswitchProvider(providerId) -- updates selectedProvider, clears
stale model listsImportant ordering:
Validate: Settings persist across restarts. Switching providers loads the correct model list. Invalid credentials show clear errors.
Create a helper that reads the current store state and produces the config object needed by the rest of the application (e.g., for sending to an agent framework or API).
{ modelId, providerId, apiKey?, baseUrl? }This is the bridge between "user configured their AI settings" and "application code needs to make an AI call."
Validate: Builder produces correct config for each provider type. Missing config produces actionable error messages.
The settings UI should be driven by the provider registry, not hardcoded. The pattern:
AVAILABLE_PROVIDERS list. Selecting a
provider updates selectedProvider in settings.type:
Key UX principle: Test-then-use. Do not allow model selection until the connection is verified. Do not allow AI operations until a model is selected. This prevents confusing errors during actual use.
Business logic consumes AI through the configuration builder, never through provider services directly:
1. User triggers an AI operation (e.g., "organize this document")
2. Configuration builder reads current settings → { modelId, providerId, ... }
3. Config is passed to the AI execution layer (agent framework, direct API call)
4. Execution layer uses providerId to create the right SDK client
5. Result flows back to the UI
The business logic layer does not import any provider service classes. It only
knows about the config shape (modelId, providerId, apiKey?, baseUrl?).
If your architecture includes a server-side component (API server, agent framework), you need a second factory for server-side SDK clients. This is separate from the client-side factory because:
testConnection or getAvailableModelsThe pattern is the same -- switch on providerId and create the right SDK
client -- but the return type is different (a language model object instead of a
service instance).
This is the key extensibility scenario. Adding a provider should require changes in exactly these locations:
No changes to business logic, no changes to the configuration builder, no changes to the settings store actions. The type guards handle the new provider automatically if it fits the cloud/local categorization.
If the new provider does not fit cloud/local categories (e.g., it requires both an API key and a custom URL), either:
Prefer adding a new category if the pattern will repeat. Use the extra-field approach for one-off cases.
| Setting | Type | Default | Purpose |
|---|---|---|---|
selectedProvider | enum | (app-specific) | Which provider is currently active |
apiKey | string | "" | API key for cloud providers |
serverUrl | string | Provider-specific default | Base URL for local providers |
selectedModel | string | "" | Selected model ID per provider |
isApiKeyValid | boolean | false | Cached key validation state |
isConnectionValid | boolean | false | Cached connection state (local) |
lastValidatedAt | string | undefined | When credentials were last verified |
autoLoadModels | boolean | true | Auto-load models on valid connection |
requestTimeout | number | 30 (seconds) | Timeout for AI requests |
abstract methods. Factory is
a plain function with a switch statement.@abstractmethod. Factory is a
function or classmethod.Box<dyn ProviderTrait>.storeToRefs for template binding.For desktop apps with a security boundary between UI and system:
If using an agent framework, the provider factory pattern has a server-side counterpart that creates framework-specific model objects:
function getAgentModel(runtimeConfig):
switch (runtimeConfig.providerId):
case 'openrouter':
return createOpenRouterSDK(runtimeConfig.apiKey)(runtimeConfig.modelId)
case 'ollama':
return createOllamaSDK(runtimeConfig.baseUrl)(runtimeConfig.modelId)
case 'lmstudio':
return createOpenAICompatibleSDK(runtimeConfig.baseUrl)(runtimeConfig.modelId)
The client-side factory creates service objects for UI operations (test connection, list models). The server-side factory creates language model objects for agent execution. Both switch on the same provider ID enum.
Separate client-side and server-side factories. They return different types (service instances vs. language model objects) and have different concerns. Do not try to unify them into one factory.
Deep-merge settings on load. When loading persisted settings, deep-merge
with defaults. If you add a new setting field, existing users' persisted
settings will not have it. Shallow merge loses nested defaults (e.g., adding a
new field to ollama settings gets lost if you only spread the top level).
Clear model lists when switching providers. If the user switches from OpenRouter to Ollama, clear the model list immediately. Showing stale OpenRouter models while Ollama models load is confusing.
Cache validation state, but re-validate on use. Store isApiKeyValid so
the UI shows the right state. But if an API call fails with 401, reset the
cached state -- the key may have been revoked.
Local providers may need response sanitization. Local models (especially
reasoning models) can return markup like <think>...</think> blocks in their
responses. Strip these before returning to the application.
Provider-specific error messages are high-value. Generic "request failed" is useless. Map HTTP status codes to actionable messages: 401 = "Invalid API key, check your settings", 429 = "Rate limited, try again later", connection refused = "Is Ollama running?", etc.
Test connection before loading models. For cloud providers, model listing may not require authentication (OpenRouter's model list is public). But testing the key first prevents a false sense of "everything works" when only model listing succeeds.
Convert reactive objects to plain objects before persisting. If using a reactive state management system (Vue reactivity, MobX, etc.), serialize to a plain object before writing to persistent storage. Reactive wrappers can cause serialization issues or circular references.
Default models should be set after model list loads. If no model is selected and models are loaded successfully, pick a sensible default (e.g., a well-known cheap model). Do not hardcode model IDs in business logic -- store them in settings with a fallback.
The provider ID enum is the source of truth. Types, factory, registry,
settings, and server-side factory all reference the same enum. If you add a
provider to the enum but forget the factory case, the default: throw catches
it immediately.