How DictationPostProcessingService Integrates with AI Providers in FluidVoice

The DictationPostProcessingService acts as a unified abstraction layer that routes raw dictation transcripts to Apple Intelligence, local private AI models, or any cloud-based LLM through an OpenAI-compatible interface, automatically handling provider resolution, credential management, and HTTP request construction.

The DictationPostProcessingService is the central orchestration component in the FluidVoice macOS application that transforms raw speech-to-text output into polished, context-aware text. When users enable AI-enhanced dictation, this Swift service coordinates between multiple AI backends—from local macOS FoundationModels to remote OpenAI, Anthropic, or custom endpoints—without requiring changes to the core dictation logic.

Provider Resolution Architecture

The integration begins in Sources/Fluid/Services/DictationPostProcessingService.swift, where the process(_:dictationSlot:) method serves as the primary entry point. This method first validates the input text, then delegates provider selection to a dedicated resolution engine.

The resolveProvider Method

The resolveProvider(settings:dictationSlot:) method implements a four-tier fallback strategy to determine which AI backend handles the request:

// Returns ResolvedProvider containing providerID, providerKey, baseURL, model, apiKey
func resolveProvider(settings: SettingsStore, 
                    dictationSlot: DictationShortcutSlot) -> ResolvedProvider

Private AI (Local LLM) – When settings.dictationPromptSelection == .privateAI and a verified model ID exists, the service returns a ResolvedProvider with an empty API key and the local model path. This triggers the PrivateAIIntegrationService pathway instead of a network call.

Saved Custom Provider – If the selected provider ID matches an entry in SettingsStore.savedProviders, the service extracts the custom baseURL, model, and stored API key, returning a provider prefixed with "custom:".

Built-in Cloud Provider – For recognized providers like OpenAI, Anthropic, or Cohere (verified via ModelRepository.shared.isBuiltIn(providerID)), the service uses default base URLs from ModelRepository alongside the selected model and globally stored API keys.

Fallback Handler – Any unrecognized provider ID passes through with raw user-supplied data, enabling integration with experimental or niche LLM endpoints.

Special Case Handlers

The service recognizes two provider types that bypass the standard HTTP flow entirely.

Apple Intelligence Integration

When the resolved providerID equals "apple-intelligence", the service instantiates AppleIntelligenceProvider and processes the request through the local macOS FoundationModels framework (available on macOS 26+). This pathway executes entirely in-process without generating HTTP requests, preserving privacy and reducing latency.

Private AI Local Models

For the "private-ai" selection, the service forwards the transcript to PrivateAIIntegrationService.enhanceDictation(...). This method handles locally-hosted or self-hosted LLM instances, managing the inference pipeline independently of the cloud-based LLMClient architecture.

Cloud Provider Request Flow

When the resolved provider represents a remote LLM, the service constructs a standardized request through three layers.

Building the LLMClient Configuration

The service prepares a LLMClient.Config struct with the following parameters:

var config = LLMClient.Config(
    messages: [
        .init(role: .system, content: ""),
        .init(role: .user, content: promptRenderedText)
    ],
    model: resolved.model,
    baseURL: resolved.baseURL,
    apiKey: resolved.apiKey,
    streaming: false,
    tools: [],
    temperature: settings.isTemperatureUnsupported(resolved.model) ? nil : 0.2,
    extraParameters: extraParams
)

Reasoning model parameters – If the selected model supports reasoning (e.g., Groq's "gpt-oss" models), the service injects additional parameters like enable_thinking or reasoning_effort into the extraParameters dictionary.

OpenAICompatibleProvider Implementation

LLMClient.shared.call(config) delegates to an AIProvider implementation. The default OpenAICompatibleProvider located in Sources/Fluid/Networking/OpenAICompatibleProvider.swift handles the actual HTTP interaction:

  • Endpoint construction – Automatically appends /chat/completions unless the base URL already contains a path
  • Local endpoint detection – Skips the Authorization header for local addresses (localhost, 127.*, 10.*, 192.168.*, 172.16-31.*) to support self-hosted models without API key requirements
  • Groq-specific handling – Adds reasoning_effort parameters for Groq reasoning models
  • Serialization – Encodes the request into a ChatRequest JSON payload and executes via URLSession
  • Response parsing – Extracts the first content field from the ChatResponse JSON structure

Error Handling and Validation

The service enforces strict validation before network requests:

  • Missing credentials – Throws AIProcessingError.noVerifiedProvider when no provider is configured, AIProcessingError.missingModel when the model identifier is empty, and AIProcessingError.missingAPIKey when the API key is required but absent
  • Empty responses – Converts empty HTTP responses or malformed JSON into AIProcessingError.emptyResponse
  • Formatting cleanup – After receiving text from any provider, the service strips formatting artifacts through ASRService.applyGAAVFormatting before returning the final Result object

Complete Implementation Example

The following pattern demonstrates how to invoke the service from a view model with proper provider checking:

import Fluid

class DictationViewModel {
    func processTranscript(_ transcript: String) async {
        // Verify configuration before attempting enhancement
        guard DictationAIPostProcessingGate.isProviderConfigured() else {
            print("No AI provider configured")
            return
        }
        
        do {
            let result = try await DictationPostProcessingService.shared.process(
                transcript, 
                dictationSlot: .primary
            )
            print("Enhanced text:", result.text)
        } catch let error as AIProcessingError {
            switch error {
            case .missingAPIKey:
                print("API key not found")
            case .emptyResponse:
                print("Model returned empty content")
            default:
                print("Processing failed:", error)
            }
        } catch {
            print("Network or system error:", error)
        }
    }
}

Configuration storage in SettingsStore follows this structure:

{
  "selectedProviderID": "openai",
  "providerAPIKeys": {
    "openai": "sk-YOUR_OPENAI_KEY",
    "anthropic": "sk-ant-..."
  },
  "selectedModelByProvider": {
    "openai": "gpt-4o-mini",
    "anthropic": "claude-3-5-sonnet-20241022"
  },
  "savedProviders": [
    {
      "id": "custom-local",
      "baseURL": "http://localhost:11434/v1",
      "models": ["llama3.1", "mistral"]
    }
  ]
}

Summary

  • DictationPostProcessingService orchestrates all AI-enhanced dictation through a single process(_:dictationSlot:) method that abstracts provider-specific complexity
  • Four-tier resolution handles Private AI, custom providers, built-in cloud providers, and fallback configurations through resolveProvider(settings:dictationSlot:)
  • Dual local pathways support Apple Intelligence (macOS FoundationModels) and Private AI (self-hosted models) without HTTP overhead
  • OpenAI-compatible abstraction enables integration with OpenAI, Anthropic, Cohere, Groq, and custom endpoints through standardized chat completion requests
  • Automatic local detection in OpenAICompatibleProvider removes authentication requirements for local network addresses (192.168.x.x, 10.x.x.x, etc.)
  • Comprehensive error handling through AIProcessingError variants ensures the UI can distinguish between configuration, network, and model-specific failures

Frequently Asked Questions

How does DictationPostProcessingService handle API keys for different providers?

The service retrieves API keys from SettingsStore.providerAPIKeys using the resolved providerID as the lookup key. For built-in providers like OpenAI or Anthropic, it accesses the global key store. For custom providers saved in SettingsStore.savedProviders, it uses the key stored within that specific provider entry. Local endpoints detected by OpenAICompatibleProvider automatically skip authentication headers entirely.

Can I use a local LLM like Ollama with DictationPostProcessingService?

Yes. Add your local endpoint (e.g., http://localhost:11434/v1) as a custom provider in SettingsStore.savedProviders. The OpenAICompatibleProvider detects local IP addresses (127.*, 192.168.*, etc.) and omits the Authorization header, allowing Ollama, LM Studio, or other OpenAI-compatible local servers to work without API keys.

What happens when Apple Intelligence is selected as the provider?

When selectedProviderID equals "apple-intelligence", the service instantiates AppleIntelligenceProvider and processes the transcript through the macOS FoundationModels framework. This bypasses LLMClient entirely, executing the inference in-process on macOS 26 or later without network requests, providing lower latency and complete privacy for the dictation text.

How does the service format the final output from AI providers?

After receiving the raw text from any provider (cloud or local), the service passes the result through ASRService.applyGAAVFormatting to remove formatting artifacts, markdown syntax, or unwanted whitespace. This ensures consistent text output regardless of whether the underlying provider was OpenAI, a local Ollama instance, or Apple Intelligence.

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 →