From 76b655bf6d51c44b682ff64050ae53ec66691f9d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 21 Jul 2026 23:42:12 +0700 Subject: [PATCH] feat(ai-providers): add llama.cpp and MLX as local providers Claude-Session: https://claude.ai/code/session_01FYwKEwKydeMG6WAHucMdY9 --- CHANGELOG.md | 1 + .../AI/Registry/AIProviderRegistration.swift | 2 +- TablePro/Models/AI/AIModels.swift | 10 +++ TablePro/Views/Settings/AISettingsView.swift | 14 ++-- .../AI/LocalProviderRegistrationTests.swift | 68 +++++++++++++++++++ docs/features/ai-assistant.mdx | 7 +- 6 files changed, 89 insertions(+), 13 deletions(-) create mode 100644 TableProTests/Core/AI/LocalProviderRegistrationTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 7be62802f..cb076c6ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Connect to PGlite through its socket server (`pglite-server`), with schema browsing, SQL editing, and row editing like PostgreSQL. Defaults to a loopback host with no TLS, and treats the connection as single-use to match PGlite's one-connection limit. (#1911) - In the CSV editor, right-click a column header to rename, insert, delete, or change the type of that column. (#1913) +- Add llama.cpp and MLX as local AI providers. Each preset points at the server's default local endpoint and needs no API key, alongside the existing Ollama and custom OpenAI-compatible options. (#1777) ### Fixed diff --git a/TablePro/Core/AI/Registry/AIProviderRegistration.swift b/TablePro/Core/AI/Registry/AIProviderRegistration.swift index bd37488ec..edd6afadd 100644 --- a/TablePro/Core/AI/Registry/AIProviderRegistration.swift +++ b/TablePro/Core/AI/Registry/AIProviderRegistration.swift @@ -85,7 +85,7 @@ enum AIProviderRegistration { } )) - for type in [AIProviderType.openRouter, .openCode, .ollama, .custom] { + for type in [AIProviderType.openRouter, .openCode, .ollama, .llamaCpp, .mlx, .custom] { var capabilities: AIProviderCapabilities = [ .chat, .models, .endpointConfigurable, .maxOutputTokens, .modelListFetchable ] diff --git a/TablePro/Models/AI/AIModels.swift b/TablePro/Models/AI/AIModels.swift index 027d57998..742ef36f5 100644 --- a/TablePro/Models/AI/AIModels.swift +++ b/TablePro/Models/AI/AIModels.swift @@ -17,6 +17,8 @@ enum AIProviderType: String, Codable, CaseIterable, Identifiable, Sendable { case gemini case xai case ollama + case llamaCpp + case mlx case openCode case custom @@ -33,6 +35,8 @@ enum AIProviderType: String, Codable, CaseIterable, Identifiable, Sendable { case .gemini: return "Gemini" case .xai: return "xAI" case .ollama: return "Ollama" + case .llamaCpp: return "llama.cpp" + case .mlx: return "MLX" case .openCode: return "OpenCode Zen" case .custom: return String(localized: "Custom") } @@ -49,6 +53,8 @@ enum AIProviderType: String, Codable, CaseIterable, Identifiable, Sendable { case .gemini: return "https://generativelanguage.googleapis.com" case .xai: return "https://api.x.ai" case .ollama: return "http://localhost:11434" + case .llamaCpp: return "http://localhost:8080" + case .mlx: return "http://localhost:8080" case .openCode: return "https://opencode.ai/zen" case .custom: return "" } @@ -67,6 +73,8 @@ enum AIProviderType: String, Codable, CaseIterable, Identifiable, Sendable { case .cursor: return .optionalApiKey case .xai: return .optionalApiKey case .ollama: return .none + case .llamaCpp: return .none + case .mlx: return .none case .openCode: return .optionalApiKey default: return .apiKey } @@ -83,6 +91,8 @@ enum AIProviderType: String, Codable, CaseIterable, Identifiable, Sendable { case .gemini: return "wand.and.stars" case .xai: return "x.circle" case .ollama: return "desktopcomputer" + case .llamaCpp: return "memorychip" + case .mlx: return "m.square" case .openCode: return "sparkles" case .custom: return "server.rack" } diff --git a/TablePro/Views/Settings/AISettingsView.swift b/TablePro/Views/Settings/AISettingsView.swift index fc13b1a72..285750456 100644 --- a/TablePro/Views/Settings/AISettingsView.swift +++ b/TablePro/Views/Settings/AISettingsView.swift @@ -333,16 +333,12 @@ struct AISettingsView: View { ? String(localized: "API key set") : String(localized: "Not configured") case .none: - if provider.type == .ollama { - let endpoint = provider.endpoint.isEmpty ? provider.type.defaultEndpoint : provider.endpoint - if let host = URL(string: endpoint)?.host, host == "localhost" || host == "127.0.0.1" { - return String(localized: "Local") - } - return endpoint + let endpoint = provider.endpoint.isEmpty ? provider.type.defaultEndpoint : provider.endpoint + guard !endpoint.isEmpty else { return String(localized: "Not configured") } + if let host = URL(string: endpoint)?.host, host == "localhost" || host == "127.0.0.1" { + return String(localized: "Local") } - return provider.endpoint.isEmpty - ? String(localized: "Not configured") - : provider.endpoint + return endpoint } } diff --git a/TableProTests/Core/AI/LocalProviderRegistrationTests.swift b/TableProTests/Core/AI/LocalProviderRegistrationTests.swift new file mode 100644 index 000000000..476086c19 --- /dev/null +++ b/TableProTests/Core/AI/LocalProviderRegistrationTests.swift @@ -0,0 +1,68 @@ +// +// LocalProviderRegistrationTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Local OpenAI-compatible provider registration") +struct LocalProviderRegistrationTests { + init() { + AIProviderRegistration.registerAll() + } + + private func descriptor(for type: AIProviderType) -> AIProviderDescriptor? { + AIProviderRegistry.shared.descriptor(for: type.rawValue) + } + + @Test("llama.cpp is a known local provider with no auth and the default llama-server endpoint") + func llamaCppType() { + #expect(AIProviderType.llamaCpp.authStyle == .none) + #expect(AIProviderType.allCases.contains(.llamaCpp)) + #expect(AIProviderType(rawValue: "llamaCpp") == .llamaCpp) + #expect(AIProviderType.llamaCpp.displayName == "llama.cpp") + #expect(AIProviderType.llamaCpp.defaultEndpoint == "http://localhost:8080") + } + + @Test("MLX is a known local provider with no auth and the default mlx_lm.server endpoint") + func mlxType() { + #expect(AIProviderType.mlx.authStyle == .none) + #expect(AIProviderType.allCases.contains(.mlx)) + #expect(AIProviderType(rawValue: "mlx") == .mlx) + #expect(AIProviderType.mlx.displayName == "MLX") + #expect(AIProviderType.mlx.defaultEndpoint == "http://localhost:8080") + } + + @Test("Local providers register OpenAI-compatible descriptors that fetch models and take an endpoint") + func descriptorCapabilities() { + for type in [AIProviderType.llamaCpp, .mlx] { + let provider = descriptor(for: type) + #expect(provider != nil) + #expect(provider?.allowsEndpointConfiguration == true) + #expect(provider?.allowsMaxOutputTokens == true) + #expect(provider?.fetchesModelList == true) + #expect(provider?.allowsNameConfiguration == false) + #expect(provider?.supportsReasoning == false) + #expect(provider?.supportsImages == false) + } + } + + @Test("Local providers build the OpenAI-compatible transport, not a bespoke one") + func makeProviderUsesOpenAICompatible() { + for type in [AIProviderType.llamaCpp, .mlx] { + let config = AIProviderConfig(type: type, model: "local-model") + #expect(descriptor(for: type)?.makeProvider(config, nil) is OpenAICompatibleProvider) + } + } + + @Test("The default local endpoint resolves to the OpenAI chat-completions path") + func defaultEndpointResolvesToOpenAIPath() { + for type in [AIProviderType.llamaCpp, .mlx] { + let config = AIProviderConfig(type: type) + #expect(config.endpoint == "http://localhost:8080") + #expect(config.endpoint.openAIPath("chat/completions") == "http://localhost:8080/v1/chat/completions") + } + } +} diff --git a/docs/features/ai-assistant.mdx b/docs/features/ai-assistant.mdx index c5b85efa8..4131a9b81 100644 --- a/docs/features/ai-assistant.mdx +++ b/docs/features/ai-assistant.mdx @@ -1,6 +1,6 @@ --- title: AI Assistant -description: "Built-in AI for SQL: chat with tool calling, inline suggestions, explain, optimize, and fix-error across 11 providers." +description: "Built-in AI for SQL: chat with tool calling, inline suggestions, explain, optimize, and fix-error across 13 providers." --- # AI Assistant @@ -16,7 +16,7 @@ Open **Settings** (`Cmd+,`) > **AI**. The **Enable AI Features** toggle at the t AI settings -1. Click **Add Provider...** and pick a type: GitHub Copilot, ChatGPT, Cursor, Claude, OpenAI, OpenRouter, OpenCode Zen, Gemini, xAI, Ollama, or a custom OpenAI-compatible endpoint. +1. Click **Add Provider...** and pick a type: GitHub Copilot, ChatGPT, Cursor, Claude, OpenAI, OpenRouter, OpenCode Zen, Gemini, xAI, Ollama, llama.cpp, MLX, or a custom OpenAI-compatible endpoint. 2. Enter the API key, or sign in for Copilot, ChatGPT, Cursor, and xAI. 3. Enter a model name, or pick one from the fetched list. 4. Click **Test Connection**. @@ -29,6 +29,7 @@ Provider notes: - **ChatGPT**: sign in with your ChatGPT account to use the Codex quota from Plus, Pro, Business, and Enterprise plans, no API key. **Import from Codex CLI** reuses an existing Codex login. Unofficial interface; may change. - **Cursor**: paste an API key from the Cursor dashboard, or leave it blank and click **Sign in with Cursor** (needs the Cursor CLI installed). Cursor runs as an agent, not a chat-completions endpoint. It cannot call TablePro's database tools, so Edit and Agent modes do not run queries through it. - **xAI**: paste an API key, or click **Sign in with xAI** to use a SuperGrok or X Premium+ subscription. Sign-in opens a Grok Build consent screen; this path is unofficial and may change. Preset models: Grok 4.5 and Grok 4.3. +- **Ollama, llama.cpp, MLX**: local providers, no API key. Each preset fills in its default endpoint (`http://localhost:11434` for Ollama, `http://localhost:8080` for `llama-server` and `mlx_lm.server`); edit it if you moved the host or port. Start the server first, then **Test Connection** and pick a model from the fetched list. llama.cpp and MLX speak the OpenAI-compatible API; for tool calling, start `llama-server` with `--jinja`. For any other OpenAI-compatible server, use the custom endpoint. ## Chat @@ -72,7 +73,7 @@ In Edit and Agent modes, each tool call appears as a card in the reply. Read-onl If the connection's safe-mode level is **Silent**, write tools auto-approve. If it is **Read Only**, they auto-deny. Destructive operations are the exception: `confirm_destructive_operation` always requires a per-call click. Silent mode does not auto-approve it, and **Always for this connection** is refused for it. The tool also requires the model to pass the verbatim phrase `I understand this is irreversible`. -Provider support for tool calling: every provider except Cursor. Ollama and custom endpoints depend on the model. +Provider support for tool calling: every provider except Cursor. Ollama, llama.cpp, MLX, and custom endpoints depend on the model. ### Attach Context with `@`