Skip to content

Commit 39c8c9e

Browse files
authored
feat(ai-providers): add OpenCode Zen provider (#1400) (#1417)
* feat(ai-providers): add OpenCode Zen provider (#1400) * fix(ai-providers): make OpenCode Zen API key optional so free models work (#1400)
1 parent 4570f90 commit 39c8c9e

11 files changed

Lines changed: 84 additions & 24 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- 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)
13+
14+
### Fixed
15+
16+
- 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)
17+
1018
## [0.45.0] - 2026-05-26
1119

1220
### Added

TablePro/Core/AI/AIProviderFactory.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ enum AIProviderFactory {
8383
guard let config else { return nil }
8484
let apiKey: String?
8585
switch config.type.authStyle {
86-
case .apiKey:
86+
case .apiKey, .optionalApiKey:
8787
apiKey = AIKeyStorage.shared.loadAPIKey(for: config.id)
8888
case .oauth, .none:
8989
apiKey = nil

TablePro/Core/AI/OpenAICompatibleProvider.swift

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -262,8 +262,7 @@ final class OpenAICompatibleProvider: ChatTransport {
262262
)
263263
}
264264
default:
265-
let chatPath = "/v1/chat/completions"
266-
guard let url = URL(string: "\(endpoint)\(chatPath)") else {
265+
guard let url = URL(string: endpoint.openAIPath("chat/completions")) else {
267266
throw AIProviderError.invalidEndpoint(endpoint)
268267
}
269268

@@ -312,10 +311,10 @@ final class OpenAICompatibleProvider: ChatTransport {
312311
turns: [ChatTurnWire],
313312
options: ChatTransportOptions
314313
) throws -> URLRequest {
315-
let chatPath = providerType == .ollama
316-
? "/api/chat"
317-
: "/v1/chat/completions"
318-
guard let url = URL(string: "\(endpoint)\(chatPath)") else {
314+
let urlString = providerType == .ollama
315+
? "\(endpoint)/api/chat"
316+
: endpoint.openAIPath("chat/completions")
317+
guard let url = URL(string: urlString) else {
319318
throw AIProviderError.invalidEndpoint(endpoint)
320319
}
321320

@@ -481,7 +480,7 @@ final class OpenAICompatibleProvider: ChatTransport {
481480
}
482481

483482
private func fetchOpenAIModels() async throws -> [String] {
484-
guard let url = URL(string: "\(endpoint)/v1/models") else {
483+
guard let url = URL(string: endpoint.openAIPath("models")) else {
485484
throw AIProviderError.invalidEndpoint(endpoint)
486485
}
487486

TablePro/Core/AI/Registry/AIProviderRegistration.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ enum AIProviderRegistration {
6464
}
6565
))
6666

67-
for type in [AIProviderType.openRouter, .ollama, .custom] {
67+
for type in [AIProviderType.openRouter, .openCode, .ollama, .custom] {
6868
registry.register(AIProviderDescriptor(
6969
typeID: type.rawValue,
7070
displayName: type.displayName,

TablePro/Core/AI/String+AIEndpoint.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,9 @@ extension String {
99
func normalizedEndpoint() -> String {
1010
hasSuffix("/") ? String(dropLast()) : self
1111
}
12+
13+
func openAIPath(_ resource: String) -> String {
14+
let base = normalizedEndpoint()
15+
return base.hasSuffix("/v1") ? "\(base)/\(resource)" : "\(base)/v1/\(resource)"
16+
}
1217
}

TablePro/Models/AI/AIModels.swift

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ enum AIProviderType: String, Codable, CaseIterable, Identifiable, Sendable {
1414
case openRouter
1515
case gemini
1616
case ollama
17+
case openCode
1718
case custom
1819

1920
var id: String { rawValue }
@@ -26,6 +27,7 @@ enum AIProviderType: String, Codable, CaseIterable, Identifiable, Sendable {
2627
case .openRouter: return "OpenRouter"
2728
case .gemini: return "Gemini"
2829
case .ollama: return "Ollama"
30+
case .openCode: return "OpenCode Zen"
2931
case .custom: return String(localized: "Custom")
3032
}
3133
}
@@ -38,17 +40,23 @@ enum AIProviderType: String, Codable, CaseIterable, Identifiable, Sendable {
3840
case .openRouter: return "https://openrouter.ai/api"
3941
case .gemini: return "https://generativelanguage.googleapis.com"
4042
case .ollama: return "http://localhost:11434"
43+
case .openCode: return "https://opencode.ai/zen"
4144
case .custom: return ""
4245
}
4346
}
4447

45-
enum AuthStyle: Sendable { case apiKey, oauth, none }
48+
enum AuthStyle: Sendable {
49+
case apiKey, optionalApiKey, oauth, none
50+
51+
var usesAPIKey: Bool { self == .apiKey || self == .optionalApiKey }
52+
}
4653

4754
var authStyle: AuthStyle {
4855
switch self {
49-
case .copilot: return .oauth
50-
case .ollama: return .none
51-
default: return .apiKey
56+
case .copilot: return .oauth
57+
case .ollama: return .none
58+
case .openCode: return .optionalApiKey
59+
default: return .apiKey
5260
}
5361
}
5462

@@ -60,6 +68,7 @@ enum AIProviderType: String, Codable, CaseIterable, Identifiable, Sendable {
6068
case .openRouter: return "globe"
6169
case .gemini: return "wand.and.stars"
6270
case .ollama: return "desktopcomputer"
71+
case .openCode: return "sparkles"
6372
case .custom: return "server.rack"
6473
}
6574
}

TablePro/ViewModels/AIChatViewModel.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,7 @@ final class AIChatViewModel {
275275
for config in pending {
276276
let apiKey: String?
277277
switch config.type.authStyle {
278-
case .apiKey:
278+
case .apiKey, .optionalApiKey:
279279
apiKey = services.aiKeyStorage.loadAPIKey(for: config.id)
280280
case .oauth, .none:
281281
apiKey = nil

TablePro/Views/Settings/AIProviderDetailSheet.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ struct AIProviderDetailSheet: View {
116116
switch draft.type.authStyle {
117117
case .apiKey:
118118
return !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
119-
case .oauth, .none:
119+
case .optionalApiKey, .oauth, .none:
120120
return true
121121
}
122122
}
@@ -132,7 +132,7 @@ struct AIProviderDetailSheet: View {
132132
@ViewBuilder
133133
private var authSection: some View {
134134
switch draft.type.authStyle {
135-
case .apiKey:
135+
case .apiKey, .optionalApiKey:
136136
apiKeyAuthSection
137137
case .oauth:
138138
copilotAuthSection
@@ -159,7 +159,7 @@ struct AIProviderDetailSheet: View {
159159
Text("Test Connection")
160160
}
161161
}
162-
.disabled(isTesting || apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
162+
.disabled(isTesting || (draft.type.authStyle == .apiKey && apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty))
163163
}
164164
if case .success = testResult {
165165
Label(String(localized: "Connection successful"), systemImage: "checkmark.circle.fill")

TablePro/Views/Settings/AISettingsView.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ struct AISettingsView: View {
213213
}
214214

215215
private var orderedAddableTypes: [AIProviderType] {
216-
[.copilot, .claude, .openAI, .openRouter, .gemini, .ollama]
216+
[.copilot, .claude, .openAI, .openRouter, .openCode, .gemini, .ollama]
217217
}
218218

219219
// MARK: - Inline Suggestions
@@ -312,7 +312,7 @@ struct AISettingsView: View {
312312
switch provider.type.authStyle {
313313
case .oauth:
314314
return copilotStatusText()
315-
case .apiKey:
315+
case .apiKey, .optionalApiKey:
316316
if provider.type == .custom {
317317
return customStatusText(for: provider)
318318
}
@@ -356,7 +356,7 @@ struct AISettingsView: View {
356356

357357
private func refreshKeyAvailability() {
358358
var ids: Set<UUID> = []
359-
for provider in settings.providers where provider.type.authStyle == .apiKey {
359+
for provider in settings.providers where provider.type.authStyle.usesAPIKey {
360360
if let key = AIKeyStorage.shared.loadAPIKey(for: provider.id), !key.isEmpty {
361361
ids.insert(provider.id)
362362
}
@@ -379,7 +379,7 @@ struct AISettingsView: View {
379379
}
380380

381381
private func saveProvider(_ provider: AIProviderConfig, apiKey: String, isNew: Bool) {
382-
if provider.type.authStyle == .apiKey {
382+
if provider.type.authStyle.usesAPIKey {
383383
AIKeyStorage.shared.saveAPIKey(apiKey, for: provider.id)
384384
}
385385

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
//
2+
// StringAIEndpointTests.swift
3+
// TableProTests
4+
//
5+
// Tests for AI endpoint path construction, including tolerance for base URLs
6+
// that already include the /v1 version segment.
7+
//
8+
9+
import Foundation
10+
import Testing
11+
12+
@testable import TablePro
13+
14+
@Suite("AI Endpoint Path")
15+
struct StringAIEndpointTests {
16+
@Test("base without version gets /v1 appended")
17+
func appendsVersionWhenMissing() {
18+
#expect("https://api.openai.com".openAIPath("chat/completions") == "https://api.openai.com/v1/chat/completions")
19+
#expect("https://openrouter.ai/api".openAIPath("chat/completions") == "https://openrouter.ai/api/v1/chat/completions")
20+
}
21+
22+
@Test("base ending in /v1 is not doubled")
23+
func doesNotDoubleVersion() {
24+
#expect("https://opencode.ai/zen/v1".openAIPath("chat/completions") == "https://opencode.ai/zen/v1/chat/completions")
25+
#expect("https://opencode.ai/zen/v1".openAIPath("models") == "https://opencode.ai/zen/v1/models")
26+
}
27+
28+
@Test("base without /v1 resolves the OpenCode Zen path")
29+
func openCodeZenWithoutVersion() {
30+
#expect("https://opencode.ai/zen".openAIPath("chat/completions") == "https://opencode.ai/zen/v1/chat/completions")
31+
#expect("https://opencode.ai/zen".openAIPath("models") == "https://opencode.ai/zen/v1/models")
32+
}
33+
34+
@Test("trailing slash is normalized before building the path")
35+
func normalizesTrailingSlash() {
36+
#expect("https://opencode.ai/zen/v1/".openAIPath("models") == "https://opencode.ai/zen/v1/models")
37+
#expect("https://api.openai.com/".openAIPath("models") == "https://api.openai.com/v1/models")
38+
}
39+
}

0 commit comments

Comments
 (0)