A Swift package that gives every app a single, unified chat interface across cloud and on-device AI providers. OpenAI, Anthropic, and Apple Intelligence all share the same protocol, message model, and optional SwiftUI components — swap providers with a one-line change.
Platforms: macOS 14+ · iOS 17+
Language: Swift 5.10+
For on-device inference, add a companion package:
- AIChatKitMLX — Apple MLX models (Apple Silicon only, text + vision)
| Product | Description |
|---|---|
AIChatCore |
Protocol, message model, shared types |
AIChatOpenAI |
OpenAI-compatible provider (OpenAI, OpenRouter, llama-server, …) |
AIChatAnthropic |
Anthropic Messages API with extended thinking support |
AIChatFoundationModels |
Apple Intelligence on-device (macOS 26+ / iOS 26+) |
AIChatUI |
ChatSession ViewModel + MarkdownMessageView + optional ChatView drop-in |
// Package.swift
.package(url: "https://github.com/NerdSnipe-Inc/AIChatKit", from: "0.1.0")
// Target dependencies — add only what you need
.product(name: "AIChatCore", package: "AIChatKit"),
.product(name: "AIChatOpenAI", package: "AIChatKit"),
.product(name: "AIChatAnthropic", package: "AIChatKit"),
.product(name: "AIChatFoundationModels", package: "AIChatKit"),
.product(name: "AIChatUI", package: "AIChatKit"),Most apps have their own design system. Use ChatSession as the ViewModel and render session.entries in your own views:
import AIChatAnthropic
import AIChatUI
@StateObject private var session = ChatSession(
provider: AnthropicProvider(apiKey: "sk-ant-…"),
model: "claude-opus-4-8",
options: ChatRequestOptions(
maxTokens: 4096,
systemPrompt: "You are a helpful assistant."
)
)
var body: some View {
ScrollView {
ForEach(session.entries) { entry in
switch entry {
case .userMessage(let e): MyUserBubble(text: e.text)
case .aiMessage(let e): MyAssistantBubble(text: e.text, isStreaming: e.isStreaming)
case .reasoning(let e): MyThinkingTile(entry: e)
case .toolCall(let e): MyToolCallRow(entry: e)
case .activity(let e): Text(e.text).foregroundStyle(.secondary)
case .knowledgeRetrieval: EmptyView()
}
}
}
TextField("Message", text: $input)
.onSubmit { session.send(input); input = "" }
}session.send(_:) is non-blocking. Entries append as tokens arrive. session.isGenerating tracks in-flight state.
If you have no design requirements, ChatView handles everything:
import AIChatUI
import AIChatOpenAI
ChatView(session: ChatSession(
provider: OpenAIProvider(apiKey: "sk-…"),
model: "gpt-4o"
))AIChatUI exports MarkdownMessageView — a fully styled markdown renderer built on swift-markdown-ui and Splash. It handles fenced code blocks with syntax highlighting, a language label, and a copy button.
import AIChatUI
// In a custom AI bubble:
case .aiMessage(let e):
MarkdownMessageView(text: e.text)Supports: headings, bold/italic, inline code, fenced code blocks (syntax-highlighted), blockquotes, lists, tables, links.
Use loadSnapshot(entries:history:) to restore a persisted conversation into both the display entries and provider history in a single call:
var snapshotEntries: [ChatSession.Entry] = []
var snapshotHistory: [ChatMessage] = []
for msg in storedMessages {
if msg.role == "user" {
snapshotEntries.append(.userMessage(ChatSession.UserEntry(id: msg.id, text: msg.content)))
snapshotHistory.append(ChatMessage(id: msg.id, role: .user, content: msg.content))
} else {
snapshotEntries.append(.aiMessage(ChatSession.AIEntry(id: msg.id, text: msg.content, isStreaming: false)))
snapshotHistory.append(ChatMessage(id: msg.id, role: .assistant, content: msg.content))
}
}
session.loadSnapshot(entries: snapshotEntries, history: snapshotHistory)
// session.send() now has full history contextimport AIChatOpenAI
let provider = OpenAIProvider(apiKey: "sk-…")
// OpenAI-compatible endpoint (OpenRouter, llama-server, Groq, …)
let provider = OpenAIProvider(
apiKey: "your-key",
endpoint: URL(string: "https://openrouter.ai/api/v1/chat/completions")!
)
// Local llama-server — disable stream_options
let provider = OpenAIProvider(apiKey: "", endpoint: llamaURL, streamUsage: false)import AIChatAnthropic
let provider = AnthropicProvider(apiKey: "sk-ant-…")
// Extended thinking
ChatRequestOptions(maxTokens: 16000, thinkingBudget: 8000)import AIChatFoundationModels
import FoundationModels
@available(macOS 26.0, iOS 26.0, *)
guard case .available = SystemLanguageModel.default.availability else { return }
let provider = FoundationModelsProvider()Context window is 4,096 tokens on-device. When the conversation exceeds this limit, FoundationModelsProvider surfaces LanguageModelSession.GenerationError.exceededContextWindowSize. Callers should catch this and either summarize history or switch to a higher-context provider (e.g. MLXProvider from AIChatKitMLX, or PrivateCloudComputeLanguageModel when it becomes available in the public SDK).
session.$error
.sink { error in
if let genError = error as? LanguageModelSession.GenerationError,
case .exceededContextWindowSize = genError {
// switch to higher-context provider and restore history
}
}import AIChatCore
let tool = ChatRequestOptions.ToolDefinition(
name: "get_weather",
description: "Get the current weather for a city.",
parameters: .object(
properties: ["city": .string(description: "City name")],
required: ["city"]
)
)
ChatRequestOptions(tools: [tool], toolChoice: .auto)When a .toolCall entry has status == .running, execute the tool and call:
session.submitToolResult(toolCallId: entry.id, content: resultString)ChatSession surfaces errors as .activity entries automatically. For custom error UI:
.onChange(of: session.error) { _, error in
guard let error else { return }
// show your own alert
}MIT