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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Requesty in Add Provider, an OpenAI-compatible router with its Base URL filled in. (#3128)

### Changed

- A new AI provider with several models and no default starts with no model picked. (#3128)
- Toolbar Save is a plain checkmark, and the Safe Mode icon is filled only at the two Full levels. (#3250)
- Actions, filter and Disconnect toolbar icons lose their circle on macOS 26 and later, and Actions its chevron. (#3250)

### Fixed

- Reasoning setting ignored on OpenRouter, OpenCode Zen, llama.cpp, MLX and custom AI providers. (#3128)
- Reasoning picker shown for Gemini and Ollama, where it changed nothing. (#3128)
- Image attachments offered for OpenRouter models that take text only. (#3128)
- Max output tokens ignored and image attachments rejected on Ollama. (#3128)
- AI provider failing with "Invalid endpoint" until relaunch after its Base URL was cleared. (#3128)
- Export in the Structure and object source views showing the Import icon. (#3250)
- VoiceOver reading the welcome window and Integrations filter menus as "chevron.pulldown". (#3250)
- Database icon filled in the database switcher and query editor, outline in the toolbar and sidebar. (#3250)
Expand Down
81 changes: 81 additions & 0 deletions TablePro/Core/AI/AIProviderDraftRules.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
//
// AIProviderDraftRules.swift
// TablePro
//

import Foundation

/// The rules the provider sheet applies to a provider being edited, kept out of the view so they
/// can be tested.
internal enum AIProviderDraftRules {
/// Which model a provider being set up starts on.
///
/// A list sorted by name has no first choice: taking one put a router's users on whichever of
/// several hundred models sorts first. So a model is picked only where someone ranked it, in
/// the app's curated list or as the provider's own default, or where there is nothing to
/// choose between.
internal static func initialModel(curated: [CuratedModel], fetched: [AIModelInfo]) -> String? {
if let first = curated.first {
return first.id
}
if let marked = fetched.first(where: \.isProviderDefault) {
return marked.id
}
return fetched.count == 1 ? fetched.first?.id : nil
}

/// Save waits for a model only while the provider's own list is there to pick one from. With
/// no list, because the fetch is blocked or failed or the provider has not been signed in to
/// yet, an empty model is saved as before and can be set later.
internal static func needsModelChoice(model: String, fetched: [AIModelInfo]) -> Bool {
model.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !fetched.isEmpty
}

/// A cleared Base URL means the default the field shows as its placeholder. Saving the empty
/// string instead left every request failing until the next launch decoded the default back in.
internal static func endpoint(_ typed: String, defaultEndpoint: String) -> String {
let trimmed = typed.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? defaultEndpoint : trimmed
}

/// The automatic fetch reads the Base URL as typed, never the default an empty field saves as.
/// It runs on a timer while the field is edited, and resolving an empty field to the default
/// would send a key typed for one host to another with no click.
internal static func modelListBlocker(
descriptor: AIProviderDescriptor?,
draft: AIProviderConfig,
apiKey: String
) -> AIModelListFetchGate.Blocker? {
AIModelListFetchGate.blocker(
fetchesModelList: descriptor?.fetchesModelList == true,
takesEndpoint: descriptor?.allowsEndpointConfiguration == true,
endpoint: draft.endpoint,
authStyle: draft.authStyle,
apiKey: apiKey
)
}

internal enum CatalogUpdate: Equatable {
case store
case remove
case refetch
case keep
}

/// What Save does with the shared model list.
///
/// The sheet fetches for a draft, which can point at a server the saved provider never did, so
/// its list reaches the catalog only on Save. When the key or Base URL changed and no list loaded
/// for the new ones yet, the old list is dropped rather than left to describe the new server,
/// and fetched again with what was saved.
internal static func catalogUpdate(
listIsCurrent: Bool,
listIsEmpty: Bool,
connectionChanged: Bool
) -> CatalogUpdate {
if listIsCurrent {
return listIsEmpty ? .remove : .store
}
return connectionChanged ? .refetch : .keep
}
}
10 changes: 2 additions & 8 deletions TablePro/Core/AI/AIProviderFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,7 @@ enum AIProviderFactory {
/// a half-typed endpoint would hand it to the session already streaming through that provider.
static func makeUncachedProvider(for config: AIProviderConfig, apiKey: String?) -> ChatTransport {
guard let descriptor = AIProviderRegistry.shared.descriptor(for: config.type.rawValue) else {
return OpenAICompatibleProvider(
endpoint: config.endpoint,
apiKey: apiKey,
providerType: config.type,
model: config.model,
maxOutputTokens: config.maxOutputTokens
)
return OpenAICompatibleProvider(config: config, apiKey: apiKey)
}
return descriptor.makeProvider(config, apiKey)
}
Expand Down Expand Up @@ -90,7 +84,7 @@ enum AIProviderFactory {
}
guard let config else { return nil }
let apiKey: String?
switch config.type.authStyle {
switch config.authStyle {
case .apiKey, .optionalApiKey:
apiKey = AIKeyStorage.shared.loadAPIKey(for: config.id)
case .oauth, .none:
Expand Down
21 changes: 10 additions & 11 deletions TablePro/Core/AI/AnthropicProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ final class AnthropicProvider: ChatTransport {
private let model: String
private let maxOutputTokens: Int
private let configuredEffort: ReasoningEffort?
private let providerID: UUID?
private let session: URLSession

init(
Expand All @@ -23,6 +24,7 @@ final class AnthropicProvider: ChatTransport {
model: String = "",
maxOutputTokens: Int = 4_096,
reasoningEffort: ReasoningEffort? = nil,
providerID: UUID? = nil,
session: URLSession = URLSession(configuration: .ephemeral)
) {
self.endpoint = endpoint.trimmingCharacters(in: .whitespacesAndNewlines)
Expand All @@ -31,6 +33,7 @@ final class AnthropicProvider: ChatTransport {
self.model = model.trimmingCharacters(in: .whitespacesAndNewlines)
self.maxOutputTokens = maxOutputTokens
self.configuredEffort = reasoningEffort
self.providerID = providerID
self.session = session
}

Expand Down Expand Up @@ -201,7 +204,7 @@ final class AnthropicProvider: ChatTransport {
throw AIProviderError.mapHTTPError(statusCode: statusCode, body: body, requestURL: request.url)
}

private func buildMessagesRequest(
func buildMessagesRequest(
turns: [ChatTurnWire],
options: ChatTransportOptions,
stream: Bool = true,
Expand All @@ -224,7 +227,8 @@ final class AnthropicProvider: ChatTransport {
options: options,
effort: effort,
maxTokens: resolvedMaxTokens,
stream: stream
stream: stream,
liveReasoning: AIModelCatalog.shared.fetchedInfo(providerID: providerID, modelID: options.model)?.reasoning
)

request.httpBody = try JSONSerialization.data(withJSONObject: body)
Expand All @@ -236,7 +240,8 @@ final class AnthropicProvider: ChatTransport {
options: ChatTransportOptions,
effort: ReasoningEffort?,
maxTokens: Int,
stream: Bool
stream: Bool,
liveReasoning: AIReasoningSupport? = nil
) throws -> [String: Any] {
var body: [String: Any] = [
"model": options.model,
Expand All @@ -249,7 +254,7 @@ final class AnthropicProvider: ChatTransport {
}

if let effort {
let reasoning = resolvedReasoning(for: options.model)
let reasoning = liveReasoning ?? offlineReasoning(for: options.model)

if let thinking = thinkingBody(for: effort, reasoning: reasoning, maxTokens: maxTokens) {
body["thinking"] = thinking
Expand All @@ -272,13 +277,7 @@ final class AnthropicProvider: ChatTransport {
return body
}

static func resolvedReasoning(for model: String) -> AIReasoningSupport {
if let live = AIModelCatalog.shared.reasoning(
providerTypeID: AIProviderType.claude.rawValue,
modelID: model
) {
return live
}
private static func offlineReasoning(for model: String) -> AIReasoningSupport {
let capabilities = AnthropicModelCapabilities.resolve(model: model)
return AIReasoningSupport(
mode: capabilities.thinkingMode == .adaptive ? .adaptive : .budgeted,
Expand Down
7 changes: 7 additions & 0 deletions TablePro/Core/AI/Chat/ChatImageInput.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ struct ChatImageInput: Codable, Equatable, Sendable {
return url.absoluteString
}
}

/// The image bytes alone, for a wire format that takes base64 rather than a URL. A remote
/// image has none to give without a download.
func base64Payload() -> String? {
guard case .cacheFile(let filename, _) = source else { return nil }
return AIImageCache.shared.read(filename: filename)?.base64EncodedString()
}
}

enum DetailHint: String, Codable, Sendable, CaseIterable, Identifiable {
Expand Down
4 changes: 3 additions & 1 deletion TablePro/Core/AI/Copilot/CopilotChatProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,9 @@ final class CopilotChatProvider: ChatTransport, @unchecked Sendable {
let models = try await client.fetchCopilotModels()
let chatModels = models.filter { $0.scopes?.contains("chat-panel") ?? false }
let sorted = chatModels.sorted { ($0.isChatDefault ?? false) && !($1.isChatDefault ?? false) }
return sorted.map { AIModelInfo(id: $0.id, displayName: $0.modelName) }
return sorted.map {
AIModelInfo(id: $0.id, displayName: $0.modelName, isProviderDefault: $0.isChatDefault ?? false)
}
}

func testConnection() async throws -> Bool {
Expand Down
66 changes: 47 additions & 19 deletions TablePro/Core/AI/Models/AIModelCatalog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,46 +5,74 @@

import Foundation

/// What each provider's own model list said about its models, one entry per provider configuration.
///
/// Keyed by configuration rather than by provider type: two custom providers are two servers, and
/// a list fetched from one used to replace what was known about the other.
final class AIModelCatalog: @unchecked Sendable {
static let shared = AIModelCatalog()

private let lock = NSLock()
private var fetched: [String: [String: AIModelInfo]] = [:]
private var fetched: [UUID: [String: AIModelInfo]] = [:]
private var refreshTokens: [UUID: UUID] = [:]

init() {}

func store(providerTypeID: String, models: [AIModelInfo]) {
func store(providerID: UUID, models: [AIModelInfo]) {
guard !models.isEmpty else { return }
lock.lock()
defer { lock.unlock() }
var byID: [String: AIModelInfo] = [:]
for model in models {
byID[model.id] = model
}
fetched[providerTypeID] = byID
refreshTokens.removeValue(forKey: providerID)
fetched[providerID] = Self.indexed(models)
}

func fetchedInfo(providerTypeID: String, modelID: String) -> AIModelInfo? {
func remove(providerID: UUID) {
lock.lock()
defer { lock.unlock() }
return fetched[providerTypeID]?[modelID]
refreshTokens.removeValue(forKey: providerID)
fetched.removeValue(forKey: providerID)
}

func resolve(providerTypeID: String, modelID: String) -> AIModelInfo {
let overlay = AIModelOverlay.info(providerTypeID: providerTypeID, modelID: modelID)
if let live = fetchedInfo(providerTypeID: providerTypeID, modelID: modelID) {
return live.merging(fallback: overlay)
}
return overlay ?? AIModelInfo(id: modelID)
/// Loads a provider's list again after Save dropped the one on record. Open chat windows read
/// the catalog at send time and never refetch on their own, so without this they would run
/// with no per-model limits until the next window.
func refresh(providerID: UUID, using transport: ChatTransport) async {
let token = beginRefresh(providerID: providerID)
let models = (try? await transport.fetchAvailableModels()) ?? []
finishRefresh(providerID: providerID, token: token, models: models)
}

/// A refresh that a newer refresh, store or removal overtook is dropped when it lands, so a slow
/// answer from the old server never describes the new one, and a deleted provider stays gone.
func beginRefresh(providerID: UUID) -> UUID {
let token = UUID()
lock.lock()
defer { lock.unlock() }
refreshTokens[providerID] = token
return token
}

func reasoning(providerTypeID: String, modelID: String) -> AIReasoningSupport? {
resolve(providerTypeID: providerTypeID, modelID: modelID).reasoning
func finishRefresh(providerID: UUID, token: UUID, models: [AIModelInfo]) {
lock.lock()
defer { lock.unlock() }
guard refreshTokens[providerID] == token else { return }
refreshTokens.removeValue(forKey: providerID)
guard !models.isEmpty else { return }
fetched[providerID] = Self.indexed(models)
}

private static func indexed(_ models: [AIModelInfo]) -> [String: AIModelInfo] {
var byID: [String: AIModelInfo] = [:]
for model in models {
byID[model.id] = model
}
return byID
}

func removeAll() {
func fetchedInfo(providerID: UUID?, modelID: String) -> AIModelInfo? {
guard let providerID else { return nil }
lock.lock()
defer { lock.unlock() }
fetched.removeAll()
return fetched[providerID]?[modelID]
}
}
28 changes: 11 additions & 17 deletions TablePro/Core/AI/Models/AIModelInfo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,18 +61,23 @@ struct AIModelInfo: Codable, Sendable, Equatable, Identifiable {
let displayName: String?
let contextWindow: Int?
let maxOutputTokens: Int?
/// Empty when the provider's model list does not say, which is most of them: a plain OpenAI
/// list carries an id and nothing else.
let modalities: Set<AIModality>
let reasoning: AIReasoningSupport?
let isDeprecated: Bool
/// The model the provider itself starts a new chat on.
let isProviderDefault: Bool

init(
id: String,
displayName: String? = nil,
contextWindow: Int? = nil,
maxOutputTokens: Int? = nil,
modalities: Set<AIModality> = [.text],
modalities: Set<AIModality> = [],
reasoning: AIReasoningSupport? = nil,
isDeprecated: Bool = false
isDeprecated: Bool = false,
isProviderDefault: Bool = false
) {
self.id = id
self.displayName = displayName
Expand All @@ -81,27 +86,16 @@ struct AIModelInfo: Codable, Sendable, Equatable, Identifiable {
self.modalities = modalities
self.reasoning = reasoning
self.isDeprecated = isDeprecated
self.isProviderDefault = isProviderDefault
}

var label: String {
guard let displayName, !displayName.isEmpty else { return id }
return displayName
}

var supportsImages: Bool {
modalities.contains(.image)
}

func merging(fallback: AIModelInfo?) -> AIModelInfo {
guard let fallback else { return self }
return AIModelInfo(
id: id,
displayName: displayName ?? fallback.displayName,
contextWindow: contextWindow ?? fallback.contextWindow,
maxOutputTokens: maxOutputTokens ?? fallback.maxOutputTokens,
modalities: modalities.isEmpty ? fallback.modalities : modalities,
reasoning: reasoning ?? fallback.reasoning,
isDeprecated: isDeprecated || fallback.isDeprecated
)
/// Nil when the provider did not state the model's modalities.
var supportsImages: Bool? {
modalities.isEmpty ? nil : modalities.contains(.image)
}
}
Loading
Loading