> ## Documentation Index
> Fetch the complete documentation index at: https://docs.swiftaiboilerplate.com/llms.txt
> Use this file to discover all available pages before exploring further.

# AI Module

> Production-ready LLM streaming via a Supabase Edge Function → OpenRouter. Server-side keys, model allowlist, and a backend you control.

<Info>
  **Full technical details** in your project at `/docs/modules/AI.md`
</Info>

## What You Get

* ✅ **Proxy architecture** - API keys server-side only (secure)
* ✅ **Streaming SSE** - Real-time token-by-token responses
* ✅ **Config-gated** - Auto-selects Proxy or Echo client from `PROXY_BASE_URL`
* ✅ **OpenRouter backend** - One Edge Function reaches OpenRouter's 500+ models; the shipped function allowlists a small set you expand
* ✅ **Cancellation support** - Stop generation mid-stream (structured-concurrency cancellation)
* ✅ **Echo fallback** - Test UI without API costs

**Time saved: 24-40 hours** of implementing streaming, proxy setup, error handling, and testing.

<Note>
  **Module coupling.** The `LLMClient` protocol and `LLMMessage` type live in the **FeatureChat**
  package, and the **AI** package re-exports FeatureChat (`@_exported import FeatureChat`). If you
  remove chat, move these two types first or `Packages/AI` will not compile. See
  [App Store 4.3 hardening](/pages/guides/app-store-4-3-hardening) for the per-module removal steps.
</Note>

## What's new in v2.2

* `ProxyLLMClient`'s public API is unchanged. The request builder and SSE stream parser are standalone files under `Sources/AI/Clients/Proxy/`, so you can swap the wire format without rewriting the client.
* Swift 6 strict concurrency throughout: protocol-typed properties use explicit `any`.

## Key Components

### LLMClient Protocol

The protocol is defined in **FeatureChat** (re-exported by the AI package). It has a single
requirement that takes just the conversation history:

```swift theme={null}
public protocol LLMClient: Sendable {
    func streamResponse(messages: [LLMMessage]) -> AsyncThrowingStream<String, Error>
}
```

`ProxyLLMClient` additionally offers a fully-parameterised overload with `model:` and
`temperature:` (the protocol method forwards to it with `nil` defaults):

```swift theme={null}
public func streamResponse(
    messages: [LLMMessage],
    model: String? = nil,
    temperature: Double? = nil
) -> AsyncThrowingStream<String, Error>
```

`LLMMessage` is a simple value type — `role` is a `String` (`"user"`, `"assistant"`, `"system"`),
not an enum:

```swift theme={null}
public struct LLMMessage: Sendable, Equatable {
    public let role: String   // "user" | "assistant" | "system"
    public let content: String
}
```

## Production Setup (From Real Code)

The boilerplate selects the LLM client in `SwiftAIBoilerplatePro/Composition/LLMClientFactory.swift`,
reading the generated `AppConfiguration` (built from `Config/Secrets.xcconfig`). It returns
`EchoLLMClient` until a real `PROXY_BASE_URL` is configured:

```swift theme={null}
@available(iOS 17.0, *)
func createLLMClient(httpClient: any HTTPClient) -> any LLMClient {
    guard let baseURL = URL(string: AppConfiguration.PROXY_BASE_URL),
          !AppConfiguration.PROXY_BASE_URL.contains("YOUR"),
          !AppConfiguration.PROXY_BASE_URL.contains("placeholder") else {
        AppLogger.info("LLM provider: EchoLLMClient (PROXY_BASE_URL not configured)", category: AppLogger.ai)
        return EchoLLMClient()
    }

    let proxyPath = AppConfiguration.PROXY_PATH

    var defaultHeaders: [String: String] = [:]
    if let headersJson = ProcessInfo.processInfo.environment["PROXY_DEFAULT_HEADERS"] {
        defaultHeaders = parseHeadersJSON(headersJson)
    }

    let proxyClient = ProxyLLMClient(
        baseURL: baseURL,
        httpClient: httpClient,  // carries the auth + retry interceptors
        path: proxyPath,
        defaultHeaders: defaultHeaders
    )

    AppLogger.info(
        "LLM provider: ProxyLLMClient (url=\(AppConfiguration.PROXY_BASE_URL), path=\(proxyPath))",
        category: AppLogger.ai
    )
    return proxyClient
}
```

`CompositionRoot` wires it up with `self.llmClient = createLLMClient(httpClient: httpClient)`. The
default generated `PROXY_PATH` is `/ai` (the Edge Function route); `EchoLLMClient` itself lives in
this same factory file, not in the AI package.

### Why This Architecture

**Proxy Pattern (Production):**

* ✅ API keys never in app (stored in Supabase secrets)
* ✅ Authentication via JWT (automatic via AuthInterceptor)
* ✅ Backend controls costs and rate limits
* ✅ Can switch models without app update

**Echo Fallback (Development):**

* ✅ Test UI without backend setup
* ✅ No API costs during development
* ✅ Perfect for rapid iteration
* ✅ Auto-enabled until `PROXY_BASE_URL` is set to a real URL (placeholder/`YOUR` values still use Echo)

## Streaming Pattern

```swift theme={null}
// In ViewModel
for try await chunk in llmClient.streamResponse(messages: messages) {
    // Update UI with each token
    currentResponse += chunk
}
```

**Benefits:**

* ✅ Low latency (first token quickly)
* ✅ Better UX (gradual appearance)
* ✅ Cancellable (stop generation)
* ✅ Memory efficient

## Supported Models

OpenRouter exposes [500+ models](https://openrouter.ai/models). For cost control, the shipped Edge
Function only forwards an explicit **allowlist** — anything else is rejected with `400 Invalid model`:

```ts theme={null}
// supabase/functions/ai/index.ts
const ALLOWED_MODELS = ['openai/gpt-3.5-turbo', 'openai/gpt-4o-mini']
// default when the client omits `model`:
const { messages, model = 'openai/gpt-3.5-turbo', temperature = 0.7 } = await req.json()
```

To use Anthropic, Google, Meta, or any other OpenRouter model, add its slug to `ALLOWED_MODELS`,
redeploy the function, then pass `model:` from the client:

```swift theme={null}
let stream = llmClient.streamResponse(
    messages: messages,
    model: "anthropic/claude-3.7-sonnet",  // must be in ALLOWED_MODELS
    temperature: 0.7
)
```

## Supabase Edge Function

The proxy keeps API keys server-side and enforces auth, entitlement, and rate limits before any
upstream cost is incurred:

```ts theme={null}
// supabase/functions/ai/index.ts
serve(async (req) => {
  // 1. Verify user auth (Supabase JWT)
  // 2. Check the active AI entitlement (server-trusted, RevenueCat-backed)
  // 3. Consume the per-user rate limit (60 req / 60 min by default)
  // 4. Validate model against ALLOWED_MODELS + bounds-check messages
  // 5. Call OpenRouter with the server-side key and stream tokens back
})
```

**Deployment:**

```bash theme={null}
supabase secrets set OPENROUTER_API_KEY=sk-or-v1-YOUR_KEY
supabase functions deploy ai
```

## Customization Examples

### Add a System Prompt

`role` is a plain `String` — use `"system"`, `"user"`, or `"assistant"`:

```swift theme={null}
let systemMessage = LLMMessage(
    role: "system",
    content: """
    You are a professional Swift developer.
    Provide clear, concise answers with code examples.
    """
)

let messages = [systemMessage] + conversationHistory + [userMessage]
```

<Note>
  The shipped Edge Function injects its own server-controlled `SYSTEM_PROMPT` and only accepts
  `user`/`assistant` roles from clients (`ALLOWED_ROLES`). Client-supplied `system` messages are
  only meaningful for a direct client you write yourself (the local `EchoLLMClient` ignores them
  and simply echoes the last user message). To change the production persona,
  edit `SYSTEM_PROMPT` in `supabase/functions/ai/index.ts`.
</Note>

### Add a Direct LLM Provider

The `LLMClient` protocol lives in **FeatureChat** (re-exported by `import AI`). Conform to it to
bypass the proxy:

```swift theme={null}
import AI

final class OpenAIClient: LLMClient {
    func streamResponse(messages: [LLMMessage]) -> AsyncThrowingStream<String, Error> {
        // Direct OpenAI API integration — no proxy
    }
}

// Then return it from createLLMClient(...) in LLMClientFactory.swift
```

## Security

**API keys never in client:**

* ✅ Edge Function holds OpenRouter key
* ✅ User auth required for proxy access
* ✅ Rate limiting at Edge Function
* ✅ No keys in client code
* ✅ Messages not logged by proxy

## Key Files

| Component                                    | Location                                                             |
| -------------------------------------------- | -------------------------------------------------------------------- |
| `LLMClient` protocol + `LLMMessage`          | `Packages/FeatureChat/Sources/FeatureChat/Protocols/LLMClient.swift` |
| AI module namespace (re-exports FeatureChat) | `Packages/AI/Sources/AI/AI.swift`                                    |
| Proxy client                                 | `Packages/AI/Sources/AI/Clients/ProxyLLMClient.swift`                |
| Proxy request builder + SSE parser           | `Packages/AI/Sources/AI/Clients/Proxy/`                              |
| Echo client + `createLLMClient` factory      | `SwiftAIBoilerplatePro/Composition/LLMClientFactory.swift`           |
| Edge Function                                | `supabase/functions/ai/index.ts`                                     |

## Dependencies

* **Core** - Error handling, logging
* **Networking** - HTTP client for proxy
* **FeatureChat** - owns `LLMClient` / `LLMMessage`; the AI package re-exports it

## Used By

* **App target** - `LLMClientFactory` builds the proxy/echo client
* **Custom features** - Your AI features

<Note>
  The boilerplate ships **no LLM**. The optional **App Generator** (a separate macOS product,
  [coming soon](/pages/reference/license)) drives your own coding-agent CLI to scaffold a
  differentiated app from this template — it does not bundle a model either.
</Note>

## Best Practices

<AccordionGroup>
  <Accordion title="Streaming">
    * Use AsyncThrowingStream
    * Handle cancellation
    * Update UI incrementally
    * Show loading state
  </Accordion>

  <Accordion title="Error Handling">
    * Map to AppError
    * Retry transient failures
    * Show user-friendly messages
    * Log technical details
  </Accordion>

  <Accordion title="Cost Management">
    * Choose appropriate model
    * Use mini/flash for simple tasks
    * Cache system prompts
    * Limit message history
  </Accordion>
</AccordionGroup>

## Learn More

<CardGroup cols={2}>
  <Card title="Full Documentation" icon="book" href="https://github.com/SwiftAIBoilerplatePro/SwiftAIBoilerplatePro-Distribution/blob/main/docs/modules/AI.md">
    Complete AI guide
  </Card>

  <Card title="Supabase Setup" icon="server" href="/pages/guides/supabase-setup">
    Deploy Edge Function
  </Card>

  <Card title="Feature Chat" icon="comments" href="/pages/modules/feature-chat">
    See AI integration
  </Card>

  <Card title="Building Guide" icon="hammer" href="/pages/guides/building-your-app">
    Customize AI experience
  </Card>
</CardGroup>

## Test Coverage

The `AITests` target covers the proxy client end to end — request building, SSE stream parsing,
error mapping, and cancellation (`Tests/AITests/Proxy/`). It runs as part of the workspace suite:
**\~598 tests across 12 package test targets + the app test suites**, in one `Boilerplate.xctestplan`
run.

Tests include:

* Request building (headers, path, body)
* SSE stream parsing and `[DONE]` termination
* Error mapping to `AppError`
* Cancellation
