ChatMCP LLM Client Factory Architecture: How It Supports Multiple Providers

ChatMCP implements a factory pattern where LLMFactory instantiates provider-specific clients that inherit from BaseLLMClient, enabling runtime selection through an enum-based switch and string-to-enum mapping while maintaining a uniform API across all LLM services.

ChatMCP is an open-source Flutter application that integrates with diverse Large Language Model services including OpenAI, Claude, Ollama, and Gemini. The daodao97/chatmcp repository decouples provider-specific HTTP logic from the core chat interface through a robust abstraction layer located in lib/llm/. This architecture allows the application to treat every LLM service identically while handling unique authentication schemas, endpoints, and response formats internally.

Core Components of the Factory Architecture

The ChatMCP LLM client factory separates three distinct concerns: provider identification, implementation instantiation, and common interface definition.

Provider Enumeration

All supported services are declared in the LLMProvider enum within lib/llm/llm_factory.dart. This serves as the authoritative registry of built-in providers and acts as the switch key for the factory.

enum LLMProvider {
  openai,
  claude,
  ollama,
  deepseek,
  gemini,
  foundry,
  claudeCode,
  copilot,
}

Source: lib/llm/llm_factory.dart

The Abstract Base Class

BaseLLMClient in lib/llm/base_llm_client.dart defines the contract that every concrete provider must implement. It standardizes both synchronous and streaming chat completions while providing shared utilities for proxy handling, error management, and endpoint construction.

abstract class BaseLLMClient {
  Future<LLMResponse> chatCompletion(CompletionRequest request);
  Stream<LLMResponse> chatStreamCompletion(CompletionRequest request);
  // Helper methods (endpoint building, proxy handling, error handling, etc.)
}

Source: lib/llm/base_llm_client.dart

Concrete Provider Implementations

Each LLM service extends BaseLLMClient with provider-specific logic. OpenAIClient, ClaudeClient, OllamaClient, DeepSeekClient, GeminiClient, FoundryClient, CopilotClient, and ClaudeCodeClient handle unique request schemas, authentication headers, and response parsing while exposing the same public methods.

How the Factory Creates LLM Clients

The instantiation logic operates through two primary pathways: direct enum-based creation and dynamic resolution from user settings.

Direct Instantiation with LLMFactory.create

The static create method receives the provider enum value and required credentials, then returns the appropriate concrete instance through an exhaustive switch statement.

static BaseLLMClient create(
  LLMProvider provider,
  {required String apiKey, required String baseUrl, String? apiVersion}) {
  switch (provider) {
    case LLMProvider.openai:   return OpenAIClient(apiKey: apiKey, baseUrl: baseUrl);
    case LLMProvider.claude:   return ClaudeClient(apiKey: apiKey, baseUrl: baseUrl);
    case LLMProvider.claudeCode: return ClaudeCodeClient();
    case LLMProvider.deepseek: return DeepSeekClient(apiKey: apiKey, baseUrl: baseUrl);
    case LLMProvider.ollama:   return OllamaClient(baseUrl: baseUrl);
    case LLMProvider.gemini:   return GeminiClient(apiKey: apiKey, baseUrl: baseUrl);
    case LLMProvider.foundry:  return FoundryClient(apiKey: apiKey, baseUrl: baseUrl, apiVersion: apiVersion);
    case LLMProvider.copilot:  return CopilotClient(apiKey: apiKey);
  }
}

Source: lib/llm/llm_factory.dart

Runtime Resolution via LLMFactoryHelper.createFromModel

When a user selects a model from the UI, createFromModel bridges the gap between stored configuration and the factory. It retrieves the LLMProviderSetting from ProviderManager, validates that the provider is enabled, logs the masked API key, resolves the provider enum via string mapping or API style fallback, and invokes LLMFactory.create.

static BaseLLMClient createFromModel(llm_model.Model currentModel) {
  final setting = ProviderManager.settingsProvider.apiSettings
      .firstWhere((e) => e.providerId == currentModel.providerId);

  if (setting.enable == false) throw Exception('Provider disabled');

  _logApiKeyUsage(currentModel.providerId, currentModel.name, setting.apiKey);
  var provider = providerMap[currentModel.providerId] ??
                 LLMProvider.values.byName(currentModel.apiStyle);

  return LLMFactory.create(provider,
      apiKey: setting.apiKey,
      baseUrl: setting.apiEndpoint,
      apiVersion: setting.apiVersion);
}

Source: lib/llm/llm_factory.dart

String-to-Enum Mapping

The UI and persistent storage use string identifiers (e.g., "openai", "claude-code"). LLMFactoryHelper maintains providerMap to translate these IDs into enum values, with a fallback to LLMProvider.values.byName using the model's apiStyle when a direct mapping is absent.

static final Map<String, LLMProvider> providerMap = {
  "openai":   LLMProvider.openai,
  "claude":   LLMProvider.claude,
  "claude-code": LLMProvider.claudeCode,
  "deepseek": LLMProvider.deepseek,
  "ollama":   LLMProvider.ollama,
  "gemini":   LLMProvider.gemini,
  "foundry":  LLMProvider.foundry,
  "copilot":  LLMProvider.copilot,
};

Source: lib/llm/llm_factory.dart

Supporting Multiple Providers Seamlessly

The architecture enables multi-provider support through uniform configuration storage, defensive fallback mechanisms, and minimal-friction extensibility.

Unified Configuration Storage

LLMProviderSetting in lib/provider/settings_provider.dart stores apiKey, apiEndpoint, apiStyle, and providerId for every service in a standardized schema. This allows the UI to present a single "Add API" form regardless of the target LLM service, while the factory handles provider-specific constructor arguments.

Source: lib/provider/settings_provider.dart

Graceful Fallback Mechanism

If createFromModel encounters an unrecognized providerId or fails to resolve the enum, the factory defaults to instantiating an OpenAIClient using the "openai" settings. This ensures the application remains stable even when configurations reference deprecated or experimental providers.

Extending the Architecture

Adding a new LLM service requires only three steps:

  1. Add the enum value to LLMProvider in lib/llm/llm_factory.dart.
  2. Implement the client class extending BaseLLMClient in a new file under lib/llm/.
  3. Register the mapping in LLMFactoryHelper.providerMap to link the string ID to the enum.

No modifications to chat logic, UI components, or request handling are necessary.

Implementation Example

The following pattern demonstrates how higher-level code consumes the factory without knowledge of the underlying provider:

import 'package:chatmcp/llm/llm_factory.dart';
import 'package:chatmcp/provider/provider_manager.dart';

// Suppose the user selects a model from the UI
void sendMessage(llm_model.Model selectedModel, List<ChatMessage> history) async {
  try {
    // 1️⃣ Build the appropriate client
    final client = LLMFactoryHelper.createFromModel(selectedModel);

    // 2️⃣ Prepare a request (model name, messages, etc.)
    final request = CompletionRequest(
      model: selectedModel.name,
      messages: history,
    );

    // 3️⃣ Call the unified API (works for any provider)
    final response = await client.chatCompletion(request);

    // 4️⃣ Handle the response...
    print('Assistant: ${response.content}');
  } catch (e) {
    // Uniform error handling across providers
    print('LLM request failed: $e');
  }
}

This approach ensures that calls to chatCompletion or chatStreamCompletion execute the correct provider-specific HTTP logic while returning data in a consistent format.

Summary

  • Factory Pattern: LLMFactory centralizes instantiation logic using an exhaustive switch on the LLMProvider enum to return concrete client instances.
  • Abstract Contract: BaseLLMClient enforces a uniform API across all providers, isolating HTTP-specific implementations from chat interface code.
  • Runtime Resolution: LLMFactoryHelper.createFromModel dynamically selects providers based on user settings, validates enabled status, and maps string IDs to enum values.
  • Extensibility: New providers require only enum registration, class implementation, and map entry—no changes to consuming code.
  • Defensive Design: The architecture includes graceful fallbacks to OpenAI-compatible clients when provider resolution fails.

Frequently Asked Questions

What is the role of BaseLLMClient in ChatMCP's architecture?

BaseLLMClient is an abstract class defined in lib/llm/base_llm_client.dart that mandates the implementation of chatCompletion and chatStreamCompletion methods. It guarantees that every provider-specific client (OpenAI, Claude, Ollama, etc.) exposes an identical interface to the rest of the application, while internally handling unique authentication, endpoint construction, and response parsing.

How does ChatMCP handle unknown or disabled providers?

The LLMFactoryHelper.createFromModel method explicitly checks the enable flag on the provider's settings and throws an exception if disabled. If the provider string ID is not found in providerMap, the factory falls back to resolving the enum via LLMProvider.values.byName(currentModel.apiStyle). If all resolution fails, the system defaults to creating an OpenAIClient, ensuring the application does not crash due to unrecognized configurations.

What files must be modified to add a new LLM provider to ChatMCP?

Adding support for a new service requires editing lib/llm/llm_factory.dart to add the provider name to the LLMProvider enum and the corresponding string mapping in providerMap. You must also create a new implementation file (e.g., lib/llm/newprovider_client.dart) that extends BaseLLMClient. The LLMFactory.create switch statement must include a case returning the new client class with appropriate constructor arguments.

How are API credentials and endpoints managed across different providers?

All provider configurations are stored as LLMProviderSetting objects in lib/provider/settings_provider.dart, which maintains a list of apiSettings containing apiKey, apiEndpoint, apiVersion, and providerId. When LLMFactoryHelper.createFromModel is invoked, it extracts these values from the settings store and passes them to LLMFactory.create, ensuring each concrete client receives the correct credentials without hard-coding sensitive data in the factory logic.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →