# LLMClient Architecture and Multi-Provider Support in FluidVoice

> Explore the LLMClient architecture in FluidVoice. This Swift layer unifies LLM interactions, supports multiple AI providers, and handles streaming responses and tool calls effectively.

- Repository: [ALTIC/FluidVoice](https://github.com/altic-dev/FluidVoice)
- Tags: architecture
- Published: 2026-07-01

---

**The LLMClient is a Swift-based communication layer that abstracts LLM interactions through a unified Config-driven interface, automatically routing requests to provider-specific endpoints while handling streaming responses, tool calls, and thinking tokens.**

The LLMClient architecture in the altic-dev/FluidVoice repository provides a single entry point for all Large Language Model interactions. It eliminates vendor lock-in by separating transport logic from provider-specific parsing, allowing the app to communicate with OpenAI, Ollama, DeepSeek, or private servers without changing calling code.

## Core Architecture Components

The LLMClient implementation in [`Sources/Fluid/Services/LLMClient.swift`](https://github.com/altic-dev/FluidVoice/blob/main/Sources/Fluid/Services/LLMClient.swift) is built around several specialized structures that handle the complete request lifecycle.

### Unified Error Handling with LLMError

All failures route through **LLMError**, a comprehensive enum covering URL validation issues, HTTP status codes, JSON encoding errors, timeout conditions, and custom validation failures. This ensures consistent error propagation regardless of which provider returns the failure.

### Config Struct for Request Parameters

The **Config** struct acts as a declarative blueprint for every LLM request. It encapsulates:

- **messages**: The conversation history array
- **model**: Provider model identifier (e.g., `gpt-5-mini`, `deepseek-coder`)
- **baseURL**: Endpoint root (e.g., `https://api.openai.com/v1` or `http://localhost:11434`)
- **apiKey**: Authentication token
- **streaming**: Boolean flag for SSE vs. synchronous responses
- **tools**: Optional function definitions for tool calling
- **temperature** and **maxTokens**: Standard inference controls
- **extraParameters**: Provider-specific extensions injected by `ThinkingParserFactory`

### Smart Endpoint Selection

The `shouldUseResponsesAPI(for:baseURL:)` method automatically detects when to use OpenAI's newer `/responses` endpoint instead of the standard `/chat/completions` path. This logic triggers for reasoning models (o1/o3/gpt-5 series) and certain private API implementations, ensuring compatibility without manual configuration.

### Response Processing Pathways

The client maintains three distinct processing pipelines in [`LLMClient.swift`](https://github.com/altic-dev/FluidVoice/blob/main/LLMClient.swift):

1. **processStreaming**: Handles Server-Sent Events (SSE) from Chat Completions endpoints, accumulating partial deltas
2. **processResponsesStreaming**: Parses the alternative Responses API format
3. **processNonStreaming**: Manages synchronous JSON responses

Each pathway extracts a standardized **Response** object containing optional thinking content, main content text, and parsed tool calls.

### Resilient Retry Logic

Transient network failures trigger `executeWithRetry(request:config:)`, which implements exponential backoff for DNS failures, timeouts, and temporary HTTP errors. This runs automatically before surfacing permanent failures to the caller.

## Multi-Provider Support Strategy

FluidVoice achieves provider agnosticism through runtime inspection of the **base URL** and **model name** rather than hardcoded vendor logic.

### ThinkingParserFactory for Model Detection

Located in [`Sources/Fluid/Services/ThinkingParsers.swift`](https://github.com/altic-dev/FluidVoice/blob/main/Sources/Fluid/Services/ThinkingParsers.swift), **ThinkingParserFactory** inspects the model string to determine both the parsing strategy and extra parameter injection:

- **StandardThinkingParser**: Handles models wrapping thinking content in `<thinking>` tags
- **NemoThinkingParser**: Detects `nemotron` or `nemo` models to inject `enable_thinking=true`
- **SeparateFieldThinkingParser**: Manages OpenAI o1/o3/gpt-5 and DeepSeek models that expose `reasoning` or `thought` as distinct JSON fields

When `SettingsStore.shared.isReasoningModel(model)` returns true, the factory automatically appends provider-specific flags like `enable_reasoning=true` for DeepSeek R1 via `getExtraParameters`.

### Dynamic Request Construction

The `buildRequest(_:)` method in [`LLMClient.swift`](https://github.com/altic-dev/FluidVoice/blob/main/LLMClient.swift) assembles the final `URLRequest` by:

1. Selecting the correct endpoint path
2. Applying authorization headers
3. Merging base Config parameters with factory-generated extras
4. Encoding the JSON body with proper Swift Date formatting

This means switching from OpenAI to a local Ollama instance requires only changing the `baseURL` and `model` strings in Config—no other code modifications.

## Implementation Examples

### Basic OpenAI API Call

```swift
let config = LLMClient.Config(
    messages: [["role": "user", "content": "Summarize this text."]],
    model: "gpt-5-mini",
    baseURL: "https://api.openai.com/v1",
    apiKey: "<YOUR-KEY>",
    streaming: true,
    tools: [],
    temperature: 0.7,
    maxTokens: 512,
    extraParameters: [:]
)

Task {
    do {
        let response = try await LLMClient.shared.call(config)
        print("Thinking:", response.thinking ?? "none")
        print("Answer:", response.content)
    } catch let error as LLMError {
        print("Request failed:", error.localizedDescription)
    }
}

```

### Local Ollama Server Configuration

```swift
let localConfig = LLMClient.Config(
    messages: [["role": "user", "content": "Translate to French."]],
    model: "deepseek-coder",
    baseURL: "http://localhost:11434",
    apiKey: "",  // No authentication required for local instances
    streaming: true,
    tools: [],
    temperature: nil,
    maxTokens: 256,
    extraParameters: [:]
)

Task {
    let response = try await LLMClient.shared.call(localConfig)
    print(response.content)  // "Traduisez..."
}

```

### Tool-Enabled Function Calling

```swift
let weatherTool: [[String: Any]] = [
    [
        "type": "function",
        "function": [
            "name": "get_weather",
            "description": "Retrieve current weather for a location.",
            "parameters": [
                "type": "object",
                "properties": [
                    "location": ["type": "string", "description": "City name"]
                ],
                "required": ["location"]
            ]
        ]
    ]
]

let toolConfig = LLMClient.Config(
    messages: [["role": "user", "content": "What's the weather in Tokyo?"]],
    model: "gpt-5-mini",
    baseURL: "https://api.openai.com/v1",
    apiKey: "<YOUR-KEY>",
    streaming: true,
    tools: weatherTool,
    temperature: 0.0,
    maxTokens: nil,
    extraParameters: [:]
)

Task {
    let response = try await LLMClient.shared.call(toolConfig)
    if let toolCall = response.toolCalls.first {
        print("Function:", toolCall.name)
        print("Arguments:", toolCall.arguments)
    }
}

```

## Extending the Architecture

Adding support for new providers requires minimal changes:

1. **Update `ThinkingParserFactory`**: Add model string detection logic (e.g., `modelLower.contains("newvendor")`) and return the appropriate parser
2. **Define Extra Parameters**: Implement `getExtraParameters` to inject provider-specific flags (e.g., `top_p` adjustments or reasoning toggles)
3. **Update `SettingsStore`**: Extend `isReasoningModel(_:)` if the new provider uses distinct reasoning logic

No modifications are needed to [`LLMClient.swift`](https://github.com/altic-dev/FluidVoice/blob/main/LLMClient.swift) or consuming view models, maintaining clean separation of concerns.

## Summary

- **LLMClient** provides a single, async-await interface for all LLM operations in [`Sources/Fluid/Services/LLMClient.swift`](https://github.com/altic-dev/FluidVoice/blob/main/Sources/Fluid/Services/LLMClient.swift)
- **Config** structs encapsulate all request parameters including provider-specific extras generated by **ThinkingParserFactory**
- **Multi-provider support** works through runtime model inspection and configurable base URLs, supporting OpenAI, Ollama, DeepSeek, and custom endpoints without code changes
- **Automatic endpoint detection** via `shouldUseResponsesAPI` handles both Chat Completions and Responses API formats
- **Standardized Response objects** normalize thinking tokens, content, and tool calls across all providers

## Frequently Asked Questions

### How does LLMClient handle different authentication schemes between providers?

**LLMClient accepts the API key directly in the Config struct and applies standard Bearer token authentication in `buildRequest(_:)`.** For providers requiring different schemes (like query parameters or custom headers), you can extend the request builder logic or pass additional headers through the `extraParameters` dictionary. Local servers like Ollama simply receive an empty string for `apiKey`.

### What triggers the use of the Responses API instead of Chat Completions?

**The `shouldUseResponsesAPI(for:baseURL:)` method checks if the model name indicates a reasoning model (o1, o3, gpt-5 series) or if the base URL matches known private API patterns.** This automatic selection ensures reasoning models receive the correct endpoint without manual configuration, while standard models continue using the classic `/chat/completions` path.

### How are "thinking" tokens extracted from different model formats?

**ThinkingParserFactory creates specialized parsers based on model name detection.** Standard models use regex to extract content between `<thinking>` tags, while DeepSeek and OpenAI reasoning models parse separate JSON fields (`reasoning`, `thought`). Nemotron models receive special handling with `enable_thinking` parameters injected automatically.

### Can LLMClient stream responses from local providers like Ollama?

**Yes, streaming works identically for local and remote providers as long as the endpoint supports Server-Sent Events.** Set `streaming: true` in Config, and `processStreaming` will handle the SSE parsing regardless of whether the `baseURL` points to OpenAI or `localhost:11434`. The client manages the partial content accumulation and completion detection automatically.