From e53d138d82b9dd97d7100f769284b7c93b77e379 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 25 May 2026 14:37:10 +0700 Subject: [PATCH 1/2] feat(ai-providers): add OpenCode Zen provider (#1400) --- CHANGELOG.md | 2 + .../Core/AI/OpenAICompatibleProvider.swift | 13 +++---- .../AI/Registry/AIProviderRegistration.swift | 2 +- TablePro/Core/AI/String+AIEndpoint.swift | 5 +++ TablePro/Models/AI/AIModels.swift | 4 ++ TablePro/Views/Settings/AISettingsView.swift | 2 +- .../Core/AI/StringAIEndpointTests.swift | 39 +++++++++++++++++++ docs/features/ai-assistant.mdx | 6 +-- 8 files changed, 61 insertions(+), 12 deletions(-) create mode 100644 TableProTests/Core/AI/StringAIEndpointTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index dae62e5ca..29ac7dfbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Changing the editor or data grid font size in Appearance settings now applies immediately and persists across relaunch, instead of resetting and leaving orphan custom themes behind (#1381) +- Custom and OpenAI-compatible AI providers now work when the base URL already ends in `/v1`, instead of building a doubled `/v1/v1/` path that failed. (#1400) ### Added +- OpenCode Zen as an AI provider. Add it like any other provider and paste your OpenCode key; the model list loads from your account, covering the Claude, GPT, Gemini, and open models Zen serves. (#1400) - Cloudflare Tunnel: connect to a database behind Cloudflare Access by letting TablePro start and stop `cloudflared access tcp` for you, the same way it manages SSH tunnels. Configure it per connection with browser sign-in or a service token. Needs cloudflared installed (`brew install cloudflared`). (#1285) - Fill Column: right-click a column header and choose Fill Column to set one value across all loaded rows. The change is staged like a normal edit, so you review it and Save before it applies, and one undo reverts the whole fill. Not available on primary key columns. (#1304) - AWS IAM authentication for PostgreSQL and MySQL connections to RDS and Aurora. Pick AWS IAM in the connection's Authentication field and use an access key, a named AWS profile, or SSO. TablePro generates a fresh login token on every connect and reconnect, so you never paste an expiring token, and SSL is required automatically. (#1291) diff --git a/TablePro/Core/AI/OpenAICompatibleProvider.swift b/TablePro/Core/AI/OpenAICompatibleProvider.swift index 41c492125..0bcb028b0 100644 --- a/TablePro/Core/AI/OpenAICompatibleProvider.swift +++ b/TablePro/Core/AI/OpenAICompatibleProvider.swift @@ -262,8 +262,7 @@ final class OpenAICompatibleProvider: ChatTransport { ) } default: - let chatPath = "/v1/chat/completions" - guard let url = URL(string: "\(endpoint)\(chatPath)") else { + guard let url = URL(string: endpoint.openAIPath("chat/completions")) else { throw AIProviderError.invalidEndpoint(endpoint) } @@ -312,10 +311,10 @@ final class OpenAICompatibleProvider: ChatTransport { turns: [ChatTurnWire], options: ChatTransportOptions ) throws -> URLRequest { - let chatPath = providerType == .ollama - ? "/api/chat" - : "/v1/chat/completions" - guard let url = URL(string: "\(endpoint)\(chatPath)") else { + let urlString = providerType == .ollama + ? "\(endpoint)/api/chat" + : endpoint.openAIPath("chat/completions") + guard let url = URL(string: urlString) else { throw AIProviderError.invalidEndpoint(endpoint) } @@ -481,7 +480,7 @@ final class OpenAICompatibleProvider: ChatTransport { } private func fetchOpenAIModels() async throws -> [String] { - guard let url = URL(string: "\(endpoint)/v1/models") else { + guard let url = URL(string: endpoint.openAIPath("models")) else { throw AIProviderError.invalidEndpoint(endpoint) } diff --git a/TablePro/Core/AI/Registry/AIProviderRegistration.swift b/TablePro/Core/AI/Registry/AIProviderRegistration.swift index 55a16d3fe..fd0bad6d9 100644 --- a/TablePro/Core/AI/Registry/AIProviderRegistration.swift +++ b/TablePro/Core/AI/Registry/AIProviderRegistration.swift @@ -64,7 +64,7 @@ enum AIProviderRegistration { } )) - for type in [AIProviderType.openRouter, .ollama, .custom] { + for type in [AIProviderType.openRouter, .openCode, .ollama, .custom] { registry.register(AIProviderDescriptor( typeID: type.rawValue, displayName: type.displayName, diff --git a/TablePro/Core/AI/String+AIEndpoint.swift b/TablePro/Core/AI/String+AIEndpoint.swift index 962edaec4..973dca9db 100644 --- a/TablePro/Core/AI/String+AIEndpoint.swift +++ b/TablePro/Core/AI/String+AIEndpoint.swift @@ -9,4 +9,9 @@ extension String { func normalizedEndpoint() -> String { hasSuffix("/") ? String(dropLast()) : self } + + func openAIPath(_ resource: String) -> String { + let base = normalizedEndpoint() + return base.hasSuffix("/v1") ? "\(base)/\(resource)" : "\(base)/v1/\(resource)" + } } diff --git a/TablePro/Models/AI/AIModels.swift b/TablePro/Models/AI/AIModels.swift index 48ae86fb6..95c187086 100644 --- a/TablePro/Models/AI/AIModels.swift +++ b/TablePro/Models/AI/AIModels.swift @@ -14,6 +14,7 @@ enum AIProviderType: String, Codable, CaseIterable, Identifiable, Sendable { case openRouter case gemini case ollama + case openCode case custom var id: String { rawValue } @@ -26,6 +27,7 @@ enum AIProviderType: String, Codable, CaseIterable, Identifiable, Sendable { case .openRouter: return "OpenRouter" case .gemini: return "Gemini" case .ollama: return "Ollama" + case .openCode: return "OpenCode Zen" case .custom: return String(localized: "Custom") } } @@ -38,6 +40,7 @@ enum AIProviderType: String, Codable, CaseIterable, Identifiable, Sendable { case .openRouter: return "https://openrouter.ai/api" case .gemini: return "https://generativelanguage.googleapis.com" case .ollama: return "http://localhost:11434" + case .openCode: return "https://opencode.ai/zen" case .custom: return "" } } @@ -60,6 +63,7 @@ enum AIProviderType: String, Codable, CaseIterable, Identifiable, Sendable { case .openRouter: return "globe" case .gemini: return "wand.and.stars" case .ollama: return "desktopcomputer" + case .openCode: return "sparkles" case .custom: return "server.rack" } } diff --git a/TablePro/Views/Settings/AISettingsView.swift b/TablePro/Views/Settings/AISettingsView.swift index e5cd8c595..55368e5a3 100644 --- a/TablePro/Views/Settings/AISettingsView.swift +++ b/TablePro/Views/Settings/AISettingsView.swift @@ -213,7 +213,7 @@ struct AISettingsView: View { } private var orderedAddableTypes: [AIProviderType] { - [.copilot, .claude, .openAI, .openRouter, .gemini, .ollama] + [.copilot, .claude, .openAI, .openRouter, .openCode, .gemini, .ollama] } // MARK: - Inline Suggestions diff --git a/TableProTests/Core/AI/StringAIEndpointTests.swift b/TableProTests/Core/AI/StringAIEndpointTests.swift new file mode 100644 index 000000000..207ce29e8 --- /dev/null +++ b/TableProTests/Core/AI/StringAIEndpointTests.swift @@ -0,0 +1,39 @@ +// +// StringAIEndpointTests.swift +// TableProTests +// +// Tests for AI endpoint path construction, including tolerance for base URLs +// that already include the /v1 version segment. +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("AI Endpoint Path") +struct StringAIEndpointTests { + @Test("base without version gets /v1 appended") + func appendsVersionWhenMissing() { + #expect("https://api.openai.com".openAIPath("chat/completions") == "https://api.openai.com/v1/chat/completions") + #expect("https://openrouter.ai/api".openAIPath("chat/completions") == "https://openrouter.ai/api/v1/chat/completions") + } + + @Test("base ending in /v1 is not doubled") + func doesNotDoubleVersion() { + #expect("https://opencode.ai/zen/v1".openAIPath("chat/completions") == "https://opencode.ai/zen/v1/chat/completions") + #expect("https://opencode.ai/zen/v1".openAIPath("models") == "https://opencode.ai/zen/v1/models") + } + + @Test("base without /v1 resolves the OpenCode Zen path") + func openCodeZenWithoutVersion() { + #expect("https://opencode.ai/zen".openAIPath("chat/completions") == "https://opencode.ai/zen/v1/chat/completions") + #expect("https://opencode.ai/zen".openAIPath("models") == "https://opencode.ai/zen/v1/models") + } + + @Test("trailing slash is normalized before building the path") + func normalizesTrailingSlash() { + #expect("https://opencode.ai/zen/v1/".openAIPath("models") == "https://opencode.ai/zen/v1/models") + #expect("https://api.openai.com/".openAIPath("models") == "https://api.openai.com/v1/models") + } +} diff --git a/docs/features/ai-assistant.mdx b/docs/features/ai-assistant.mdx index 2723b0079..3cef7db94 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, fix-error. 7 providers." +description: "Built-in AI for SQL: chat with tool calling, inline suggestions, explain, optimize, fix-error. 8 providers." --- # AI Assistant @@ -28,7 +28,7 @@ Open **Settings** (`Cmd+,`) > **AI**. The tab is modeled on Xcode's Intelligence ### Add a Provider -1. Click **Add Provider...** and pick a type: GitHub Copilot, Claude, OpenAI, OpenRouter, Gemini, Ollama, or a custom OpenAI-compatible endpoint. +1. Click **Add Provider...** and pick a type: GitHub Copilot, Claude, OpenAI, OpenRouter, OpenCode Zen, Gemini, Ollama, or a custom OpenAI-compatible endpoint. 2. Enter the API key, or run device-flow sign-in for Copilot. 3. Enter a model name, or pick one from the fetched list. Click **Reload** if needed. 4. Click **Test Connection**. @@ -177,7 +177,7 @@ The cap is 10 tool round trips per turn. If you hit it, send a follow-up to cont | `execute_query` | Run `SELECT` / `INSERT` / `UPDATE` / `DELETE`. Multi-statement input rejected. Destructive DDL blocked. | Edit, Agent | | `confirm_destructive_operation` | Run destructive DDL after the model passes the verbatim phrase `I understand this is irreversible` | Agent | -Provider support: Claude, OpenAI, OpenRouter, Gemini, Ollama (model-dependent), GitHub Copilot, and custom OpenAI-compatible endpoints. +Provider support: Claude, OpenAI, OpenRouter, OpenCode Zen, Gemini, Ollama (model-dependent), GitHub Copilot, and custom OpenAI-compatible endpoints. ### Attach Context with `@` From ccf4e5af9db2a78ca373f3b429ea8bec2582266a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 25 May 2026 15:47:52 +0700 Subject: [PATCH 2/2] fix(ai-providers): make OpenCode Zen API key optional so free models work (#1400) --- CHANGELOG.md | 2 +- TablePro/Core/AI/AIProviderFactory.swift | 2 +- TablePro/Models/AI/AIModels.swift | 13 +++++++++---- TablePro/ViewModels/AIChatViewModel.swift | 2 +- TablePro/Views/Settings/AIProviderDetailSheet.swift | 6 +++--- TablePro/Views/Settings/AISettingsView.swift | 6 +++--- 6 files changed, 18 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29ac7dfbd..b867f2606 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Custom and OpenAI-compatible AI providers now work when the base URL already ends in `/v1`, instead of building a doubled `/v1/v1/` path that failed. (#1400) ### Added -- OpenCode Zen as an AI provider. Add it like any other provider and paste your OpenCode key; the model list loads from your account, covering the Claude, GPT, Gemini, and open models Zen serves. (#1400) +- OpenCode Zen as an AI provider. Add it from the provider list and paste an OpenCode key, or leave the key blank to use the free models; the model list loads automatically, covering the Claude, GPT, Gemini, and open models Zen serves. (#1400) - Cloudflare Tunnel: connect to a database behind Cloudflare Access by letting TablePro start and stop `cloudflared access tcp` for you, the same way it manages SSH tunnels. Configure it per connection with browser sign-in or a service token. Needs cloudflared installed (`brew install cloudflared`). (#1285) - Fill Column: right-click a column header and choose Fill Column to set one value across all loaded rows. The change is staged like a normal edit, so you review it and Save before it applies, and one undo reverts the whole fill. Not available on primary key columns. (#1304) - AWS IAM authentication for PostgreSQL and MySQL connections to RDS and Aurora. Pick AWS IAM in the connection's Authentication field and use an access key, a named AWS profile, or SSO. TablePro generates a fresh login token on every connect and reconnect, so you never paste an expiring token, and SSL is required automatically. (#1291) diff --git a/TablePro/Core/AI/AIProviderFactory.swift b/TablePro/Core/AI/AIProviderFactory.swift index 24f43d9ea..42d859009 100644 --- a/TablePro/Core/AI/AIProviderFactory.swift +++ b/TablePro/Core/AI/AIProviderFactory.swift @@ -83,7 +83,7 @@ enum AIProviderFactory { guard let config else { return nil } let apiKey: String? switch config.type.authStyle { - case .apiKey: + case .apiKey, .optionalApiKey: apiKey = AIKeyStorage.shared.loadAPIKey(for: config.id) case .oauth, .none: apiKey = nil diff --git a/TablePro/Models/AI/AIModels.swift b/TablePro/Models/AI/AIModels.swift index 95c187086..facb38d0d 100644 --- a/TablePro/Models/AI/AIModels.swift +++ b/TablePro/Models/AI/AIModels.swift @@ -45,13 +45,18 @@ enum AIProviderType: String, Codable, CaseIterable, Identifiable, Sendable { } } - enum AuthStyle: Sendable { case apiKey, oauth, none } + enum AuthStyle: Sendable { + case apiKey, optionalApiKey, oauth, none + + var usesAPIKey: Bool { self == .apiKey || self == .optionalApiKey } + } var authStyle: AuthStyle { switch self { - case .copilot: return .oauth - case .ollama: return .none - default: return .apiKey + case .copilot: return .oauth + case .ollama: return .none + case .openCode: return .optionalApiKey + default: return .apiKey } } diff --git a/TablePro/ViewModels/AIChatViewModel.swift b/TablePro/ViewModels/AIChatViewModel.swift index 3c09401d7..56de61e25 100644 --- a/TablePro/ViewModels/AIChatViewModel.swift +++ b/TablePro/ViewModels/AIChatViewModel.swift @@ -275,7 +275,7 @@ final class AIChatViewModel { for config in pending { let apiKey: String? switch config.type.authStyle { - case .apiKey: + case .apiKey, .optionalApiKey: apiKey = services.aiKeyStorage.loadAPIKey(for: config.id) case .oauth, .none: apiKey = nil diff --git a/TablePro/Views/Settings/AIProviderDetailSheet.swift b/TablePro/Views/Settings/AIProviderDetailSheet.swift index ad8ac5967..5d30e98fc 100644 --- a/TablePro/Views/Settings/AIProviderDetailSheet.swift +++ b/TablePro/Views/Settings/AIProviderDetailSheet.swift @@ -116,7 +116,7 @@ struct AIProviderDetailSheet: View { switch draft.type.authStyle { case .apiKey: return !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - case .oauth, .none: + case .optionalApiKey, .oauth, .none: return true } } @@ -132,7 +132,7 @@ struct AIProviderDetailSheet: View { @ViewBuilder private var authSection: some View { switch draft.type.authStyle { - case .apiKey: + case .apiKey, .optionalApiKey: apiKeyAuthSection case .oauth: copilotAuthSection @@ -159,7 +159,7 @@ struct AIProviderDetailSheet: View { Text("Test Connection") } } - .disabled(isTesting || apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .disabled(isTesting || (draft.type.authStyle == .apiKey && apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)) } if case .success = testResult { Label(String(localized: "Connection successful"), systemImage: "checkmark.circle.fill") diff --git a/TablePro/Views/Settings/AISettingsView.swift b/TablePro/Views/Settings/AISettingsView.swift index 55368e5a3..28d6fbae7 100644 --- a/TablePro/Views/Settings/AISettingsView.swift +++ b/TablePro/Views/Settings/AISettingsView.swift @@ -312,7 +312,7 @@ struct AISettingsView: View { switch provider.type.authStyle { case .oauth: return copilotStatusText() - case .apiKey: + case .apiKey, .optionalApiKey: if provider.type == .custom { return customStatusText(for: provider) } @@ -356,7 +356,7 @@ struct AISettingsView: View { private func refreshKeyAvailability() { var ids: Set = [] - for provider in settings.providers where provider.type.authStyle == .apiKey { + for provider in settings.providers where provider.type.authStyle.usesAPIKey { if let key = AIKeyStorage.shared.loadAPIKey(for: provider.id), !key.isEmpty { ids.insert(provider.id) } @@ -379,7 +379,7 @@ struct AISettingsView: View { } private func saveProvider(_ provider: AIProviderConfig, apiKey: String, isNew: Bool) { - if provider.type.authStyle == .apiKey { + if provider.type.authStyle.usesAPIKey { AIKeyStorage.shared.saveAPIKey(apiKey, for: provider.id) }