Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- 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)

### Fixed

- 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)

## [0.45.0] - 2026-05-26

### Added
Expand Down
2 changes: 1 addition & 1 deletion TablePro/Core/AI/AIProviderFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 6 additions & 7 deletions TablePro/Core/AI/OpenAICompatibleProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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)
}

Expand Down
2 changes: 1 addition & 1 deletion TablePro/Core/AI/Registry/AIProviderRegistration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions TablePro/Core/AI/String+AIEndpoint.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
}
}
17 changes: 13 additions & 4 deletions TablePro/Models/AI/AIModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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")
}
}
Expand All @@ -38,17 +40,23 @@ 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 ""
}
}

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
}
}

Expand All @@ -60,6 +68,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"
}
}
Expand Down
2 changes: 1 addition & 1 deletion TablePro/ViewModels/AIChatViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions TablePro/Views/Settings/AIProviderDetailSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand All @@ -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
Expand All @@ -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")
Expand Down
8 changes: 4 additions & 4 deletions TablePro/Views/Settings/AISettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -356,7 +356,7 @@ struct AISettingsView: View {

private func refreshKeyAvailability() {
var ids: Set<UUID> = []
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)
}
Expand All @@ -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)
}

Expand Down
39 changes: 39 additions & 0 deletions TableProTests/Core/AI/StringAIEndpointTests.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
6 changes: 3 additions & 3 deletions docs/features/ai-assistant.mdx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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**.
Expand Down Expand Up @@ -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 `@`

Expand Down
Loading