From 0372d1639b67a8a66c40c5e525521af643e99135 Mon Sep 17 00:00:00 2001 From: Thibault Jaigu Date: Thu, 24 Sep 2026 21:56:33 +0100 Subject: [PATCH 1/2] feat(ai-providers): add Requesty as an AI provider --- CHANGELOG.md | 4 ++++ TablePro/Core/AI/Registry/AIProviderRegistration.swift | 2 +- TablePro/Models/AI/AIModels.swift | 4 ++++ TableProTests/Core/AI/AIEndpointTests.swift | 1 + TableProTests/Core/AI/AIProviderCapabilitiesTests.swift | 2 +- docs/security/privacy.mdx | 2 +- 6 files changed, 12 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5a1820025..10ef32e6b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Requesty as an AI provider, an OpenAI-compatible router. (#3128 by @Thibaultjaigu) + ## [0.76.0] - 2026-09-28 Agent mode: one AI session that works across the whole connection window, with every statement it ran. diff --git a/TablePro/Core/AI/Registry/AIProviderRegistration.swift b/TablePro/Core/AI/Registry/AIProviderRegistration.swift index 6b93883a24..ecb4b802a3 100644 --- a/TablePro/Core/AI/Registry/AIProviderRegistration.swift +++ b/TablePro/Core/AI/Registry/AIProviderRegistration.swift @@ -101,7 +101,7 @@ enum AIProviderRegistration { } )) - for type in [AIProviderType.openRouter, .openCode, .ollama, .llamaCpp, .mlx, .custom] { + for type in [AIProviderType.openRouter, .requesty, .openCode, .ollama, .llamaCpp, .mlx, .custom] { var capabilities: AIProviderCapabilities = [ .chat, .models, .reasoning, .images, .endpointConfigurable, .maxOutputTokens, .modelListFetchable diff --git a/TablePro/Models/AI/AIModels.swift b/TablePro/Models/AI/AIModels.swift index 4b6ac1f4d2..0e4bb973b8 100644 --- a/TablePro/Models/AI/AIModels.swift +++ b/TablePro/Models/AI/AIModels.swift @@ -15,6 +15,7 @@ enum AIProviderType: String, Codable, CaseIterable, Identifiable, Sendable { case claudeAgent case openAI case openRouter + case requesty case gemini case xai case ollama @@ -34,6 +35,7 @@ enum AIProviderType: String, Codable, CaseIterable, Identifiable, Sendable { case .claudeAgent: return "Claude Agent" case .openAI: return "OpenAI" case .openRouter: return "OpenRouter" + case .requesty: return "Requesty" case .gemini: return "Gemini" case .xai: return "xAI" case .ollama: return "Ollama" @@ -53,6 +55,7 @@ enum AIProviderType: String, Codable, CaseIterable, Identifiable, Sendable { case .claudeAgent: return "" case .openAI: return "https://api.openai.com" case .openRouter: return "https://openrouter.ai/api" + case .requesty: return "https://router.requesty.ai" case .gemini: return "https://generativelanguage.googleapis.com" case .xai: return "https://api.x.ai" case .ollama: return "http://localhost:11434" @@ -106,6 +109,7 @@ enum AIProviderType: String, Codable, CaseIterable, Identifiable, Sendable { case .claudeAgent: return "terminal" case .openAI: return "cpu" case .openRouter: return "globe" + case .requesty: return "arrow.triangle.branch" case .gemini: return "wand.and.stars" case .xai: return "x.circle" case .ollama: return "desktopcomputer" diff --git a/TableProTests/Core/AI/AIEndpointTests.swift b/TableProTests/Core/AI/AIEndpointTests.swift index d26511eb54..ec76b8bc07 100644 --- a/TableProTests/Core/AI/AIEndpointTests.swift +++ b/TableProTests/Core/AI/AIEndpointTests.swift @@ -22,6 +22,7 @@ struct AIEndpointTests { #expect(chatURL("https://api.openai.com", .chatCompletions) == "https://api.openai.com/v1/chat/completions") #expect(modelsURL("https://api.openai.com", .chatCompletions) == "https://api.openai.com/v1/models") #expect(chatURL("https://openrouter.ai/api", .chatCompletions) == "https://openrouter.ai/api/v1/chat/completions") + #expect(chatURL("https://router.requesty.ai", .chatCompletions) == "https://router.requesty.ai/v1/chat/completions") #expect(chatURL("https://opencode.ai/zen", .chatCompletions) == "https://opencode.ai/zen/v1/chat/completions") #expect(chatURL("http://localhost:8080", .chatCompletions) == "http://localhost:8080/v1/chat/completions") } diff --git a/TableProTests/Core/AI/AIProviderCapabilitiesTests.swift b/TableProTests/Core/AI/AIProviderCapabilitiesTests.swift index 91d6327897..3c1b68b511 100644 --- a/TableProTests/Core/AI/AIProviderCapabilitiesTests.swift +++ b/TableProTests/Core/AI/AIProviderCapabilitiesTests.swift @@ -39,7 +39,7 @@ struct AIProviderCapabilitiesTests { @Test("HTTP API-key providers accept max output tokens, a configurable endpoint, and model fetch") func standardHTTPProviders() { - for type in [AIProviderType.openAI, .claude, .gemini, .xai, .openRouter, .ollama] { + for type in [AIProviderType.openAI, .claude, .gemini, .xai, .openRouter, .requesty, .ollama] { let provider = descriptor(type) #expect(provider?.allowsMaxOutputTokens == true, "\(type.rawValue) should accept max output tokens") #expect(provider?.allowsEndpointConfiguration == true, "\(type.rawValue) should allow endpoint config") diff --git a/docs/security/privacy.mdx b/docs/security/privacy.mdx index f0bc2fdaba..9b817fb365 100644 --- a/docs/security/privacy.mdx +++ b/docs/security/privacy.mdx @@ -50,7 +50,7 @@ On iPhone and iPad the heartbeat is off until you choose **Share Usage Data**, a Nothing reaches a model until you add a provider, and a fresh install has none: with none configured, every AI path returns before it builds a request. **Enable AI Features** in **Settings > AI** is on out of the box, but it gates an empty tab. -One provider is marked active and answers inline suggestions and the editor actions; the chat panel's own model picker can send a turn to any other provider you configured. Requests go to that provider's endpoint. The presets are `api.anthropic.com`, `api.openai.com`, `generativelanguage.googleapis.com`, `api.x.ai`, `openrouter.ai`, `api.cursor.com` and `opencode.ai`, plus `http://localhost:11434` for Ollama and `http://localhost:8080` for llama.cpp and MLX, which keeps their traffic on the machine. Every endpoint field is editable, and a custom provider goes wherever you point it. +One provider is marked active and answers inline suggestions and the editor actions; the chat panel's own model picker can send a turn to any other provider you configured. Requests go to that provider's endpoint. The presets are `api.anthropic.com`, `api.openai.com`, `generativelanguage.googleapis.com`, `api.x.ai`, `openrouter.ai`, `router.requesty.ai`, `api.cursor.com` and `opencode.ai`, plus `http://localhost:11434` for Ollama and `http://localhost:8080` for llama.cpp and MLX, which keeps their traffic on the machine. Every endpoint field is editable, and a custom provider goes wherever you point it. A chat turn carries: From 59a7485d037fc5b079f37f5cbf329c516dc6f656 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 4 Oct 2026 02:20:49 +0700 Subject: [PATCH 2/2] feat(ai-providers): add Requesty as a preset over the custom provider Requesty was a new AIProviderType case. That raw value is stored in the synced AI settings, and no released build can decode it, so the older build's next push would have deleted the provider. It is now an AIProviderPreset stored as a custom provider with a preset id, which every build decodes. Shared OpenAI-compatible path, found while reviewing the PR: - A wrong key that a server answers with 403 is an authentication failure for a preset that says so, not a retryable server error. - A chosen reasoning effort is sent as reasoning_effort. Gemini and Ollama lose the picker, because their transports send no effort. - The model catalog is keyed by provider configuration, and keeps what a router's model list says about vision and reasoning per model. The composer and the effort picker follow it. - A new provider no longer starts on the first of a list sorted by name, and Save waits for a model while a list is there to pick from. - A cleared Base URL saves as the default instead of an empty string. - Ollama gets options.num_predict and base64 images on its native route. - AIProviderDescriptor drops the name, endpoint and symbol it copied from AIProviderType, and the capability flags nothing read. --- CHANGELOG.md | 8 +- TablePro/Core/AI/AIProviderDraftRules.swift | 63 +++++++ TablePro/Core/AI/AIProviderFactory.swift | 10 +- TablePro/Core/AI/AnthropicProvider.swift | 19 +- TablePro/Core/AI/Chat/ChatImageInput.swift | 7 + .../Core/AI/Copilot/CopilotChatProvider.swift | 4 +- TablePro/Core/AI/Models/AIModelCatalog.swift | 35 ++-- TablePro/Core/AI/Models/AIModelInfo.swift | 28 ++- .../AI/OpenAICompatibleProvider+Models.swift | 47 +++++ .../Core/AI/OpenAICompatibleProvider.swift | 93 ++++++++-- .../AI/Registry/AIProviderDescriptor.swift | 40 ++--- .../AI/Registry/AIProviderRegistration.swift | 93 ++++------ TablePro/Models/AI/AIModels.swift | 69 ++++++-- TablePro/Models/AI/AIProviderPreset.swift | 38 ++++ TablePro/ViewModels/AIChatViewModel.swift | 16 +- .../Settings/AIProviderDetailSheet.swift | 100 ++++++++--- TablePro/Views/Settings/AISettingsView.swift | 46 +++-- TableProTests/Core/AI/AIEndpointTests.swift | 1 - .../Core/AI/AIModelCatalogTests.swift | 89 +++++----- .../Core/AI/AIProviderCapabilitiesTests.swift | 50 +++++- .../Core/AI/AIProviderDraftRulesTests.swift | 122 +++++++++++++ .../Core/AI/AIProviderModelFetchTests.swift | 17 ++ .../Core/AI/AIProviderPresetTests.swift | 165 ++++++++++++++++++ .../AI/AnthropicProviderEncodingTests.swift | 20 ++- .../Core/AI/ClaudeAgentProviderTests.swift | 2 +- .../AI/LocalProviderRegistrationTests.swift | 3 +- .../OpenAICompatibleModelDecodingTests.swift | 85 +++++++++ ...nAICompatibleProviderConnectionTests.swift | 94 +++++++++- ...OpenAICompatibleProviderRequestTests.swift | 137 +++++++++++++++ .../AIChatViewModelImageGateTests.swift | 56 ++++++ TableProUITests/AIProviderPresetUITests.swift | 49 ++++++ docs/features/ai-assistant.mdx | 9 +- 32 files changed, 1345 insertions(+), 270 deletions(-) create mode 100644 TablePro/Core/AI/AIProviderDraftRules.swift create mode 100644 TablePro/Core/AI/OpenAICompatibleProvider+Models.swift create mode 100644 TablePro/Models/AI/AIProviderPreset.swift create mode 100644 TableProTests/Core/AI/AIProviderDraftRulesTests.swift create mode 100644 TableProTests/Core/AI/AIProviderPresetTests.swift create mode 100644 TableProTests/Core/AI/OpenAICompatibleModelDecodingTests.swift create mode 100644 TableProTests/Core/AI/OpenAICompatibleProviderRequestTests.swift create mode 100644 TableProTests/ViewModels/AIChatViewModelImageGateTests.swift create mode 100644 TableProUITests/AIProviderPresetUITests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 15fe9cb3e0..21fe7c481b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,15 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Requesty as an AI provider, an OpenAI-compatible router. (#3128) +- 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) diff --git a/TablePro/Core/AI/AIProviderDraftRules.swift b/TablePro/Core/AI/AIProviderDraftRules.swift new file mode 100644 index 0000000000..f3458c1317 --- /dev/null +++ b/TablePro/Core/AI/AIProviderDraftRules.swift @@ -0,0 +1,63 @@ +// +// 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 + } + + internal enum CatalogUpdate: Equatable { + case store + case remove + 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. A list that did not load for the draft's key and + /// Base URL is not stored, and when either of those changed, the list fetched with the old ones + /// is dropped rather than left to describe the new server. + internal static func catalogUpdate( + listIsCurrent: Bool, + listIsEmpty: Bool, + connectionChanged: Bool + ) -> CatalogUpdate { + if listIsCurrent { + return listIsEmpty ? .remove : .store + } + return connectionChanged ? .remove : .keep + } +} diff --git a/TablePro/Core/AI/AIProviderFactory.swift b/TablePro/Core/AI/AIProviderFactory.swift index e9b188d5cb..87ac8543c5 100644 --- a/TablePro/Core/AI/AIProviderFactory.swift +++ b/TablePro/Core/AI/AIProviderFactory.swift @@ -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) } @@ -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: diff --git a/TablePro/Core/AI/AnthropicProvider.swift b/TablePro/Core/AI/AnthropicProvider.swift index 2e699a3200..8c5b133e91 100644 --- a/TablePro/Core/AI/AnthropicProvider.swift +++ b/TablePro/Core/AI/AnthropicProvider.swift @@ -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( @@ -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) @@ -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 } @@ -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) @@ -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, @@ -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 @@ -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, diff --git a/TablePro/Core/AI/Chat/ChatImageInput.swift b/TablePro/Core/AI/Chat/ChatImageInput.swift index 7636d91916..98f6e32a10 100644 --- a/TablePro/Core/AI/Chat/ChatImageInput.swift +++ b/TablePro/Core/AI/Chat/ChatImageInput.swift @@ -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 { diff --git a/TablePro/Core/AI/Copilot/CopilotChatProvider.swift b/TablePro/Core/AI/Copilot/CopilotChatProvider.swift index 0f9a4cf6fe..aa4767045b 100644 --- a/TablePro/Core/AI/Copilot/CopilotChatProvider.swift +++ b/TablePro/Core/AI/Copilot/CopilotChatProvider.swift @@ -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 { diff --git a/TablePro/Core/AI/Models/AIModelCatalog.swift b/TablePro/Core/AI/Models/AIModelCatalog.swift index e058788b69..b458ef5b30 100644 --- a/TablePro/Core/AI/Models/AIModelCatalog.swift +++ b/TablePro/Core/AI/Models/AIModelCatalog.swift @@ -5,46 +5,39 @@ 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]] = [:] 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 - } - - func fetchedInfo(providerTypeID: String, modelID: String) -> AIModelInfo? { lock.lock() defer { lock.unlock() } - return fetched[providerTypeID]?[modelID] + fetched[providerID] = byID } - 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) - } - - func reasoning(providerTypeID: String, modelID: String) -> AIReasoningSupport? { - resolve(providerTypeID: providerTypeID, modelID: modelID).reasoning + func remove(providerID: UUID) { + lock.lock() + defer { lock.unlock() } + fetched.removeValue(forKey: providerID) } - 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] } } diff --git a/TablePro/Core/AI/Models/AIModelInfo.swift b/TablePro/Core/AI/Models/AIModelInfo.swift index f21d6ff4df..c03e681491 100644 --- a/TablePro/Core/AI/Models/AIModelInfo.swift +++ b/TablePro/Core/AI/Models/AIModelInfo.swift @@ -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 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 = [.text], + modalities: Set = [], reasoning: AIReasoningSupport? = nil, - isDeprecated: Bool = false + isDeprecated: Bool = false, + isProviderDefault: Bool = false ) { self.id = id self.displayName = displayName @@ -81,6 +86,7 @@ struct AIModelInfo: Codable, Sendable, Equatable, Identifiable { self.modalities = modalities self.reasoning = reasoning self.isDeprecated = isDeprecated + self.isProviderDefault = isProviderDefault } var label: String { @@ -88,20 +94,8 @@ struct AIModelInfo: Codable, Sendable, Equatable, Identifiable { 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) } } diff --git a/TablePro/Core/AI/OpenAICompatibleProvider+Models.swift b/TablePro/Core/AI/OpenAICompatibleProvider+Models.swift new file mode 100644 index 0000000000..fdee326404 --- /dev/null +++ b/TablePro/Core/AI/OpenAICompatibleProvider+Models.swift @@ -0,0 +1,47 @@ +// +// OpenAICompatibleProvider+Models.swift +// TablePro +// + +import Foundation + +extension OpenAICompatibleProvider { + /// Routers say more about a model than its id, in two shapes: Requesty's flat `supports_*` + /// flags, and OpenRouter's `architecture` and `supported_parameters`. A plain OpenAI list has + /// neither, and then nothing is claimed about the model. + static func decodeModel(_ json: [String: Any]) -> AIModelInfo? { + guard let id = json["id"] as? String, !id.isEmpty else { return nil } + let topProvider = json["top_provider"] as? [String: Any] + return AIModelInfo( + id: id, + contextWindow: json["context_window"] as? Int ?? json["context_length"] as? Int, + maxOutputTokens: json["max_output_tokens"] as? Int ?? topProvider?["max_completion_tokens"] as? Int, + modalities: decodeModalities(json), + reasoning: decodeReasoning(json) + ) + } + + private static func decodeModalities(_ json: [String: Any]) -> Set { + if let vision = json["supports_vision"] as? Bool { + return vision ? [.text, .image] : [.text] + } + let architecture = json["architecture"] as? [String: Any] + if let inputs = architecture?["input_modalities"] as? [String] { + return inputs.contains("image") ? [.text, .image] : [.text] + } + return [] + } + + private static func decodeReasoning(_ json: [String: Any]) -> AIReasoningSupport? { + let supported: Bool + if let flag = json["supports_reasoning"] as? Bool { + supported = flag + } else if let parameters = json["supported_parameters"] as? [String] { + supported = parameters.contains("reasoning") + } else { + return nil + } + guard supported else { return .unsupported } + return AIReasoningSupport(mode: .effortOnly, effortLevels: [.low, .medium, .high]) + } +} diff --git a/TablePro/Core/AI/OpenAICompatibleProvider.swift b/TablePro/Core/AI/OpenAICompatibleProvider.swift index 56b7e8b5b2..54680ef07b 100644 --- a/TablePro/Core/AI/OpenAICompatibleProvider.swift +++ b/TablePro/Core/AI/OpenAICompatibleProvider.swift @@ -19,6 +19,9 @@ final class OpenAICompatibleProvider: ChatTransport { private let providerType: AIProviderType private let model: String private let maxOutputTokens: Int? + private let treatsForbiddenAsAuthFailure: Bool + private let providerID: UUID? + private let catalog: AIModelCatalog private let session: URLSession private var testConnectionModel: String { model.isEmpty ? "test" : model @@ -30,6 +33,9 @@ final class OpenAICompatibleProvider: ChatTransport { providerType: AIProviderType, model: String = "", maxOutputTokens: Int? = nil, + treatsForbiddenAsAuthFailure: Bool = false, + providerID: UUID? = nil, + catalog: AIModelCatalog = .shared, session: URLSession = URLSession(configuration: .ephemeral) ) { let style = providerType.endpointStyle @@ -40,9 +46,29 @@ final class OpenAICompatibleProvider: ChatTransport { self.providerType = providerType self.model = model.trimmingCharacters(in: .whitespacesAndNewlines) self.maxOutputTokens = maxOutputTokens + self.treatsForbiddenAsAuthFailure = treatsForbiddenAsAuthFailure + self.providerID = providerID + self.catalog = catalog self.session = session } + convenience init( + config: AIProviderConfig, + apiKey: String?, + session: URLSession = URLSession(configuration: .ephemeral) + ) { + self.init( + endpoint: config.endpoint, + apiKey: apiKey, + providerType: config.type, + model: config.model, + maxOutputTokens: config.maxOutputTokens, + treatsForbiddenAsAuthFailure: config.preset?.rejectsBadKeyWithForbidden ?? false, + providerID: config.id, + session: session + ) + } + private func requestURL(_ resource: String) throws -> URL { guard let url = resolvedEndpoint?.url(appending: resource) else { throw AIProviderError.invalidEndpoint(endpoint) @@ -57,6 +83,7 @@ final class OpenAICompatibleProvider: ChatTransport { let providerType = self.providerType return SSEEventStream.make( session: session, + treatForbiddenAsAuthFailure: treatsForbiddenAsAuthFailure, buildRequest: { [self] in try buildChatCompletionRequest(turns: turns, options: options) }, decodeLine: { Self.decodeStreamLine($0, providerType: providerType) }, makeState: { OpenAIStreamState() }, @@ -209,16 +236,8 @@ final class OpenAICompatibleProvider: ChatTransport { } func fetchAvailableModels() async throws -> [AIModelInfo] { - try await fetchModelIDs().map { AIModelInfo(id: $0) } - } - - private func fetchModelIDs() async throws -> [String] { - switch providerType { - case .ollama: - return try await fetchOllamaModels() - default: - return try await fetchOpenAIModels() - } + guard providerType == .ollama else { return try await fetchOpenAIModels() } + return try await fetchOllamaModels().map { AIModelInfo(id: $0) } } func testConnection() async throws -> Bool { @@ -273,7 +292,7 @@ final class OpenAICompatibleProvider: ChatTransport { let statusCode = httpResponse.statusCode - if statusCode == 401 { + if statusCode == 401 || (statusCode == 403 && treatsForbiddenAsAuthFailure) { throw AIProviderError.authenticationFailed("") } @@ -282,7 +301,12 @@ final class OpenAICompatibleProvider: ChatTransport { } let errorBody = String(data: data, encoding: .utf8) ?? "" - throw AIProviderError.mapHTTPError(statusCode: statusCode, body: errorBody, requestURL: url) + throw AIProviderError.mapHTTPError( + statusCode: statusCode, + body: errorBody, + treatForbiddenAsAuthFailure: treatsForbiddenAsAuthFailure, + requestURL: url + ) } } @@ -296,7 +320,7 @@ final class OpenAICompatibleProvider: ChatTransport { return (try? JSONSerialization.jsonObject(with: data)) != nil } - private func buildChatCompletionRequest( + func buildChatCompletionRequest( turns: [ChatTurnWire], options: ChatTransportOptions ) throws -> URLRequest { @@ -329,13 +353,23 @@ final class OpenAICompatibleProvider: ChatTransport { let resolvedMaxTokens = options.maxOutputTokens ?? maxOutputTokens if let resolvedMaxTokens { - body["max_tokens"] = resolvedMaxTokens + /// Ollama's native route reads the limit from `options` and ignores a top-level + /// `max_tokens`. + if providerType == .ollama { + body["options"] = ["num_predict": resolvedMaxTokens] + } else { + body["max_tokens"] = resolvedMaxTokens + } } if providerType != .ollama { body["stream_options"] = ["include_usage": true] } + if let effort = options.reasoningEffort, acceptsReasoningEffort(model: options.model) { + body["reasoning_effort"] = effort.openAIWireValue + } + if !options.tools.isEmpty { body["tools"] = try options.tools.map { try encodeTool($0) } } @@ -344,6 +378,16 @@ final class OpenAICompatibleProvider: ChatTransport { return request } + /// The effort is withheld only from a model its own server lists as non-reasoning. A server + /// that says nothing about its models is taken at the user's word. + private func acceptsReasoningEffort(model: String) -> Bool { + guard providerType != .ollama else { return false } + guard let reasoning = catalog.fetchedInfo(providerID: providerID, modelID: model)?.reasoning else { + return true + } + return reasoning.sendsEffortParameter + } + func encodeTurn(_ turn: ChatTurnWire) -> [[String: Any]] { let toolUseBlocks = turn.blocks.compactMap { block -> ToolUseBlock? in if case .toolUse(let useBlock) = block.kind { return useBlock } @@ -407,6 +451,10 @@ final class OpenAICompatibleProvider: ChatTransport { return messages } + if turn.role == .user, !imageBlocks.isEmpty, providerType == .ollama { + return ollamaImageMessage(text: textContent, images: imageBlocks) + } + if turn.role == .user, !imageBlocks.isEmpty { var parts: [[String: Any]] = [] if !textContent.isEmpty { @@ -447,6 +495,18 @@ final class OpenAICompatibleProvider: ChatTransport { }.joined() } + /// Ollama's native route takes a string `content` with the images beside it as base64. It + /// rejects the content-part array the OpenAI wire format uses. + private func ollamaImageMessage(text: String, images: [ChatImageInput]) -> [[String: Any]] { + let payloads = images.compactMap { $0.base64Payload() } + guard !text.isEmpty || !payloads.isEmpty else { return [] } + var message: [String: Any] = ["role": "user", "content": text] + if !payloads.isEmpty { + message["images"] = payloads + } + return [message] + } + private func chatCompletionsImagePart(_ input: ChatImageInput) -> [String: Any]? { guard let url = input.imageURLString() else { return nil } return [ @@ -470,7 +530,7 @@ final class OpenAICompatibleProvider: ChatTransport { ] } - private func fetchOpenAIModels() async throws -> [String] { + private func fetchOpenAIModels() async throws -> [AIModelInfo] { let url = try requestURL(style.modelsResource) var request = URLRequest(url: url) @@ -504,6 +564,7 @@ final class OpenAICompatibleProvider: ChatTransport { throw AIProviderError.mapHTTPError( statusCode: httpResponse.statusCode, body: body, + treatForbiddenAsAuthFailure: treatsForbiddenAsAuthFailure, requestURL: url ) } @@ -520,7 +581,7 @@ final class OpenAICompatibleProvider: ChatTransport { ) } - return modelsArray.compactMap { $0["id"] as? String }.sorted() + return modelsArray.compactMap(Self.decodeModel(_:)).sorted { $0.id < $1.id } } private func fetchOllamaModels() async throws -> [String] { diff --git a/TablePro/Core/AI/Registry/AIProviderDescriptor.swift b/TablePro/Core/AI/Registry/AIProviderDescriptor.swift index 31217f3824..a85ce002bc 100644 --- a/TablePro/Core/AI/Registry/AIProviderDescriptor.swift +++ b/TablePro/Core/AI/Registry/AIProviderDescriptor.swift @@ -8,15 +8,12 @@ import Foundation struct AIProviderCapabilities: OptionSet, Sendable { let rawValue: UInt16 - static let chat = AIProviderCapabilities(rawValue: 1 << 0) - static let inline = AIProviderCapabilities(rawValue: 1 << 1) - static let models = AIProviderCapabilities(rawValue: 1 << 2) - static let reasoning = AIProviderCapabilities(rawValue: 1 << 3) - static let images = AIProviderCapabilities(rawValue: 1 << 4) - static let endpointConfigurable = AIProviderCapabilities(rawValue: 1 << 5) - static let nameConfigurable = AIProviderCapabilities(rawValue: 1 << 6) - static let maxOutputTokens = AIProviderCapabilities(rawValue: 1 << 7) - static let modelListFetchable = AIProviderCapabilities(rawValue: 1 << 8) + static let reasoning = AIProviderCapabilities(rawValue: 1 << 0) + static let images = AIProviderCapabilities(rawValue: 1 << 1) + static let endpointConfigurable = AIProviderCapabilities(rawValue: 1 << 2) + static let nameConfigurable = AIProviderCapabilities(rawValue: 1 << 3) + static let maxOutputTokens = AIProviderCapabilities(rawValue: 1 << 4) + static let modelListFetchable = AIProviderCapabilities(rawValue: 1 << 5) } struct CuratedModel: Sendable, Identifiable, Equatable { @@ -38,12 +35,10 @@ struct CuratedModel: Sendable, Identifiable, Equatable { } } +/// How a provider type behaves. Its name, icon and default endpoint live on `AIProviderType`. struct AIProviderDescriptor: Sendable { let typeID: String - let displayName: String - let defaultEndpoint: String let capabilities: AIProviderCapabilities - let symbolName: String let curatedModels: [CuratedModel] let showsTelemetryToggle: Bool let defaultTelemetryEnabled: Bool @@ -62,9 +57,11 @@ struct AIProviderDescriptor: Sendable { curatedModels.first(where: { $0.id == id }) } - func supportedEffortLevels(forModelID id: String) -> [ReasoningEffort] { + /// `fetched` is what the provider's own model list said about the model, when it has been + /// loaded. It wins over the offline tables, and its silence leaves the provider's envelope open. + func supportedEffortLevels(forModelID id: String, fetched: AIModelInfo? = nil) -> [ReasoningEffort] { guard supportsReasoning else { return [] } - if let reasoning = AIModelCatalog.shared.reasoning(providerTypeID: typeID, modelID: id) { + if let reasoning = fetched?.reasoning ?? AIModelOverlay.reasoning(providerTypeID: typeID, modelID: id) { return reasoning.effortLevels } if let effortLevelResolver { @@ -76,22 +73,14 @@ struct AIProviderDescriptor: Sendable { return [.low, .medium, .high] } - func modelInfo(forModelID id: String) -> AIModelInfo { - AIModelCatalog.shared.resolve(providerTypeID: typeID, modelID: id) - } - - func supportsImages(forModelID id: String) -> Bool { + func supportsImages(fetched: AIModelInfo?) -> Bool { guard supportsImages else { return false } - guard let live = AIModelCatalog.shared.fetchedInfo(providerTypeID: typeID, modelID: id) else { return true } - return live.supportsImages + return fetched?.supportsImages ?? true } init( typeID: String, - displayName: String, - defaultEndpoint: String, capabilities: AIProviderCapabilities, - symbolName: String, curatedModels: [CuratedModel] = [], showsTelemetryToggle: Bool = false, defaultTelemetryEnabled: Bool = false, @@ -100,10 +89,7 @@ struct AIProviderDescriptor: Sendable { makeProvider: @escaping @Sendable (AIProviderConfig, String?) -> ChatTransport ) { self.typeID = typeID - self.displayName = displayName - self.defaultEndpoint = defaultEndpoint self.capabilities = capabilities - self.symbolName = symbolName self.curatedModels = curatedModels self.showsTelemetryToggle = showsTelemetryToggle self.defaultTelemetryEnabled = defaultTelemetryEnabled diff --git a/TablePro/Core/AI/Registry/AIProviderRegistration.swift b/TablePro/Core/AI/Registry/AIProviderRegistration.swift index ecb4b802a3..66e99615bc 100644 --- a/TablePro/Core/AI/Registry/AIProviderRegistration.swift +++ b/TablePro/Core/AI/Registry/AIProviderRegistration.swift @@ -11,10 +11,7 @@ enum AIProviderRegistration { registry.register(AIProviderDescriptor( typeID: AIProviderType.claude.rawValue, - displayName: "Claude", - defaultEndpoint: "https://api.anthropic.com", - capabilities: [.chat, .models, .reasoning, .images, .endpointConfigurable, .maxOutputTokens, .modelListFetchable], - symbolName: "brain", + capabilities: [.reasoning, .images, .endpointConfigurable, .maxOutputTokens, .modelListFetchable], curatedModels: claudeCuratedModels, effortLevelResolver: { AnthropicModelCapabilities.effortLevels(forModel: $0) }, makeProvider: { config, apiKey in @@ -25,32 +22,26 @@ enum AIProviderRegistration { maxOutputTokens: config.maxOutputTokens ?? config.reasoningEffort?.autoScaledMaxOutputTokens ?? 4_096, - reasoningEffort: config.reasoningEffort + reasoningEffort: config.reasoningEffort, + providerID: config.id ) } )) registry.register(AIProviderDescriptor( typeID: AIProviderType.claudeAgent.rawValue, - displayName: AIProviderType.claudeAgent.displayName, - defaultEndpoint: "", - capabilities: [.chat, .models], - symbolName: AIProviderType.claudeAgent.symbolName, + capabilities: [], curatedModels: ClaudeAgent.curatedModels, makeProvider: { config, _ in ClaudeAgentProvider(model: config.model) } )) + /// Gemini has no `.reasoning`: its transport sends no thinking configuration, so an effort + /// picker there would change nothing. registry.register(AIProviderDescriptor( typeID: AIProviderType.gemini.rawValue, - displayName: "Gemini", - defaultEndpoint: "https://generativelanguage.googleapis.com", - capabilities: [ - .chat, .models, .reasoning, .images, - .endpointConfigurable, .maxOutputTokens, .modelListFetchable - ], - symbolName: "wand.and.stars", + capabilities: [.images, .endpointConfigurable, .maxOutputTokens, .modelListFetchable], makeProvider: { config, apiKey in GeminiProvider( endpoint: config.endpoint, @@ -62,10 +53,7 @@ enum AIProviderRegistration { registry.register(AIProviderDescriptor( typeID: AIProviderType.openAI.rawValue, - displayName: AIProviderType.openAI.displayName, - defaultEndpoint: AIProviderType.openAI.defaultEndpoint, - capabilities: [.chat, .models, .reasoning, .images, .endpointConfigurable, .maxOutputTokens, .modelListFetchable], - symbolName: iconForType(.openAI), + capabilities: [.reasoning, .images, .endpointConfigurable, .maxOutputTokens, .modelListFetchable], curatedModels: openAICuratedModels, makeProvider: { config, apiKey in OpenAIResponsesProvider( @@ -79,13 +67,7 @@ enum AIProviderRegistration { registry.register(AIProviderDescriptor( typeID: AIProviderType.xai.rawValue, - displayName: AIProviderType.xai.displayName, - defaultEndpoint: AIProviderType.xai.defaultEndpoint, - capabilities: [ - .chat, .models, .reasoning, .images, - .endpointConfigurable, .maxOutputTokens, .modelListFetchable - ], - symbolName: iconForType(.xai), + capabilities: [.reasoning, .images, .endpointConfigurable, .maxOutputTokens, .modelListFetchable], curatedModels: XAI.apiCuratedModels, makeProvider: { config, apiKey in if let apiKey, !apiKey.isEmpty { @@ -101,38 +83,19 @@ enum AIProviderRegistration { } )) - for type in [AIProviderType.openRouter, .requesty, .openCode, .ollama, .llamaCpp, .mlx, .custom] { - var capabilities: AIProviderCapabilities = [ - .chat, .models, .reasoning, .images, - .endpointConfigurable, .maxOutputTokens, .modelListFetchable - ] - if type == .custom { - capabilities.insert(.nameConfigurable) - } + for type in AIProviderType.openAICompatibleFamily { registry.register(AIProviderDescriptor( typeID: type.rawValue, - displayName: type.displayName, - defaultEndpoint: type.defaultEndpoint, - capabilities: capabilities, - symbolName: iconForType(type), + capabilities: openAICompatibleCapabilities(for: type), makeProvider: { config, apiKey in - OpenAICompatibleProvider( - endpoint: config.endpoint, - apiKey: apiKey, - providerType: config.type, - model: config.model, - maxOutputTokens: config.maxOutputTokens - ) + OpenAICompatibleProvider(config: config, apiKey: apiKey) } )) } registry.register(AIProviderDescriptor( typeID: AIProviderType.copilot.rawValue, - displayName: "GitHub Copilot", - defaultEndpoint: "", - capabilities: [.chat, .models, .modelListFetchable], - symbolName: AIProviderType.copilot.symbolName, + capabilities: [.modelListFetchable], showsTelemetryToggle: true, defaultTelemetryEnabled: true, oauthFlowKind: .deviceCode, @@ -141,10 +104,7 @@ enum AIProviderRegistration { registry.register(AIProviderDescriptor( typeID: AIProviderType.chatgptCodex.rawValue, - displayName: AIProviderType.chatgptCodex.displayName, - defaultEndpoint: "", - capabilities: [.chat, .inline, .models, .reasoning], - symbolName: AIProviderType.chatgptCodex.symbolName, + capabilities: [.reasoning], curatedModels: chatGPTCodexCuratedModels, oauthFlowKind: .browserRedirect, makeProvider: { config, _ in @@ -154,10 +114,7 @@ enum AIProviderRegistration { registry.register(AIProviderDescriptor( typeID: AIProviderType.cursor.rawValue, - displayName: AIProviderType.cursor.displayName, - defaultEndpoint: "", - capabilities: [.chat, .inline, .models, .modelListFetchable], - symbolName: AIProviderType.cursor.symbolName, + capabilities: [.modelListFetchable], curatedModels: cursorCuratedModels, makeProvider: { config, apiKey in if let apiKey, !apiKey.isEmpty { @@ -168,6 +125,22 @@ enum AIProviderRegistration { )) } + /// Reasoning and images are an envelope here, narrowed per model by what the server's own + /// model list says. Ollama is the exception: its native route takes `think`, not the + /// `reasoning_effort` this transport sends. + private static func openAICompatibleCapabilities(for type: AIProviderType) -> AIProviderCapabilities { + var capabilities: AIProviderCapabilities = [ + .images, .endpointConfigurable, .maxOutputTokens, .modelListFetchable + ] + if type != .ollama { + capabilities.insert(.reasoning) + } + if type == .custom { + capabilities.insert(.nameConfigurable) + } + return capabilities + } + private static let cursorCuratedModels: [CuratedModel] = CursorAI.curatedModels.map { CuratedModel(id: $0.id, displayName: $0.name) } @@ -203,8 +176,4 @@ enum AIProviderRegistration { curatedModel(id: "claude-sonnet-5", displayName: "Claude Sonnet 5", provider: .claude), curatedModel(id: "claude-haiku-4-5", displayName: "Claude Haiku 4.5", provider: .claude, defaultEffort: .low) ] - - private static func iconForType(_ type: AIProviderType) -> String { - type.symbolName - } } diff --git a/TablePro/Models/AI/AIModels.swift b/TablePro/Models/AI/AIModels.swift index 0e4bb973b8..1edc42824c 100644 --- a/TablePro/Models/AI/AIModels.swift +++ b/TablePro/Models/AI/AIModels.swift @@ -15,7 +15,6 @@ enum AIProviderType: String, Codable, CaseIterable, Identifiable, Sendable { case claudeAgent case openAI case openRouter - case requesty case gemini case xai case ollama @@ -35,7 +34,6 @@ enum AIProviderType: String, Codable, CaseIterable, Identifiable, Sendable { case .claudeAgent: return "Claude Agent" case .openAI: return "OpenAI" case .openRouter: return "OpenRouter" - case .requesty: return "Requesty" case .gemini: return "Gemini" case .xai: return "xAI" case .ollama: return "Ollama" @@ -55,7 +53,6 @@ enum AIProviderType: String, Codable, CaseIterable, Identifiable, Sendable { case .claudeAgent: return "" case .openAI: return "https://api.openai.com" case .openRouter: return "https://openrouter.ai/api" - case .requesty: return "https://router.requesty.ai" case .gemini: return "https://generativelanguage.googleapis.com" case .xai: return "https://api.x.ai" case .ollama: return "http://localhost:11434" @@ -77,29 +74,40 @@ enum AIProviderType: String, Codable, CaseIterable, Identifiable, Sendable { case .copilot: return .oauth case .chatgptCodex: return .oauth case .cursor: return .optionalApiKey + case .claude: return .apiKey case .claudeAgent: return .none + case .openAI: return .apiKey + case .openRouter: return .apiKey + case .gemini: return .apiKey case .xai: return .optionalApiKey case .ollama: return .none case .llamaCpp: return .none case .mlx: return .none case .openCode: return .optionalApiKey case .custom: return .optionalApiKey - default: return .apiKey } } /// How the configured endpoint is turned into a request URL. Providers that reach a fixed - /// host ignore it, so the fall-through matches `AIProviderFactory`'s own fallback transport. + /// host ignore it, so they answer with `AIProviderFactory`'s own fallback transport. var endpointStyle: AIEndpointStyle { switch self { - case .claude: return .messages - case .openAI, .xai: return .responses - case .gemini: return .gemini - case .ollama: return .ollama - default: return .chatCompletions + case .claude: + return .messages + case .openAI, .xai: + return .responses + case .gemini: + return .gemini + case .ollama: + return .ollama + case .copilot, .chatgptCodex, .cursor, .claudeAgent, .openRouter, .llamaCpp, .mlx, .openCode, .custom: + return .chatCompletions } } + /// The types `OpenAICompatibleProvider` serves, which share one registration. + static let openAICompatibleFamily: [AIProviderType] = [.openRouter, .openCode, .ollama, .llamaCpp, .mlx, .custom] + var symbolName: String { switch self { case .copilot: return "chevron.left.forwardslash.chevron.right" @@ -109,7 +117,6 @@ enum AIProviderType: String, Codable, CaseIterable, Identifiable, Sendable { case .claudeAgent: return "terminal" case .openAI: return "cpu" case .openRouter: return "globe" - case .requesty: return "arrow.triangle.branch" case .gemini: return "wand.and.stars" case .xai: return "x.circle" case .ollama: return "desktopcomputer" @@ -127,6 +134,10 @@ struct AIProviderConfig: Codable, Equatable, Identifiable, Sendable { let id: UUID var name: String var type: AIProviderType + /// Set on a `.custom` provider added from an `AIProviderPreset`. An id this build does not + /// know is kept as it stands, so a preset from a newer build survives a round trip through + /// this one and reads as a plain custom provider in the meantime. + var presetID: String? var model: String var endpoint: String var maxOutputTokens: Int? @@ -137,6 +148,7 @@ struct AIProviderConfig: Codable, Equatable, Identifiable, Sendable { id: UUID = UUID(), name: String = "", type: AIProviderType = .claude, + presetID: String? = nil, model: String = "", endpoint: String = "", maxOutputTokens: Int? = nil, @@ -146,28 +158,57 @@ struct AIProviderConfig: Codable, Equatable, Identifiable, Sendable { self.id = id self.name = name self.type = type + self.presetID = presetID self.model = model - self.endpoint = endpoint.isEmpty ? type.defaultEndpoint : endpoint + self.endpoint = endpoint.isEmpty ? Self.defaultEndpoint(type: type, presetID: presetID) : endpoint self.maxOutputTokens = maxOutputTokens self.telemetryEnabled = telemetryEnabled self.reasoningEffort = reasoningEffort } + /// The name is stored rather than derived, so a build without this preset still lists the + /// provider under the vendor's name. + init(preset: AIProviderPreset) { + self.init(name: preset.displayName, type: .custom, presetID: preset.id) + } + init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decode(UUID.self, forKey: .id) name = try container.decodeIfPresent(String.self, forKey: .name) ?? "" type = try container.decode(AIProviderType.self, forKey: .type) + presetID = try container.decodeIfPresent(String.self, forKey: .presetID) model = try container.decodeIfPresent(String.self, forKey: .model) ?? "" let rawEndpoint = try container.decodeIfPresent(String.self, forKey: .endpoint) ?? "" - endpoint = rawEndpoint.isEmpty ? type.defaultEndpoint : rawEndpoint + endpoint = rawEndpoint.isEmpty ? Self.defaultEndpoint(type: type, presetID: presetID) : rawEndpoint maxOutputTokens = try container.decodeIfPresent(Int.self, forKey: .maxOutputTokens) telemetryEnabled = try container.decodeIfPresent(Bool.self, forKey: .telemetryEnabled) ?? false reasoningEffort = try container.decodeIfPresent(ReasoningEffort.self, forKey: .reasoningEffort) } + var preset: AIProviderPreset? { + guard type == .custom else { return nil } + return AIProviderPreset.preset(withID: presetID) + } + + /// What kind of provider this is, as opposed to what the user named it. + var kindName: String { preset?.displayName ?? type.displayName } + var displayName: String { - name.isEmpty ? type.displayName : name + name.isEmpty ? kindName : name + } + + var symbolName: String { preset?.symbolName ?? type.symbolName } + + var authStyle: AIProviderType.AuthStyle { preset?.authStyle ?? type.authStyle } + + var defaultEndpoint: String { Self.defaultEndpoint(type: type, presetID: presetID) } + + private static func defaultEndpoint(type: AIProviderType, presetID: String?) -> String { + guard type == .custom, let preset = AIProviderPreset.preset(withID: presetID) else { + return type.defaultEndpoint + } + return preset.endpoint } } diff --git a/TablePro/Models/AI/AIProviderPreset.swift b/TablePro/Models/AI/AIProviderPreset.swift new file mode 100644 index 0000000000..a25b599e33 --- /dev/null +++ b/TablePro/Models/AI/AIProviderPreset.swift @@ -0,0 +1,38 @@ +// +// AIProviderPreset.swift +// TablePro +// + +import Foundation + +/// A named vendor that speaks the OpenAI-compatible wire format. +/// +/// A preset is stored as a `.custom` provider carrying the preset's id, so a build that has never +/// heard of the vendor still decodes it as a working custom provider. A new `AIProviderType` case +/// cannot do that: its raw value fails to decode on every older build that syncs the same settings. +struct AIProviderPreset: Identifiable, Equatable, Sendable { + let id: String + let displayName: String + let endpoint: String + let symbolName: String + let authStyle: AIProviderType.AuthStyle + /// Most servers answer a wrong key with 401. One that answers 403 would otherwise be reported + /// as a server error, which the chat offers to retry. + let rejectsBadKeyWithForbidden: Bool + + static let requesty = AIProviderPreset( + id: "requesty", + displayName: "Requesty", + endpoint: "https://router.requesty.ai", + symbolName: "arrow.triangle.branch", + authStyle: .apiKey, + rejectsBadKeyWithForbidden: true + ) + + static let all: [AIProviderPreset] = [.requesty] + + static func preset(withID id: String?) -> AIProviderPreset? { + guard let id else { return nil } + return all.first { $0.id == id } + } +} diff --git a/TablePro/ViewModels/AIChatViewModel.swift b/TablePro/ViewModels/AIChatViewModel.swift index fd4fcda4e1..f05394e9aa 100644 --- a/TablePro/ViewModels/AIChatViewModel.swift +++ b/TablePro/ViewModels/AIChatViewModel.swift @@ -217,10 +217,16 @@ final class AIChatViewModel: ObservableObject { let settings = services.appSettings.ai let configID = selectedProviderId ?? settings.activeProviderID guard let configID, - let config = settings.providers.first(where: { $0.id == configID }), - let descriptor = AIProviderRegistry.shared.descriptor(for: config.type.rawValue) + let config = settings.providers.first(where: { $0.id == configID }) else { return false } - return descriptor.supportsImages + return Self.acceptsImages(config: config, model: selectedModel ?? config.model, catalog: .shared) + } + + /// The provider type sets the envelope, and the provider's own model list narrows it: a + /// router lists text-only models beside vision ones, and an image sent to one of those fails. + nonisolated static func acceptsImages(config: AIProviderConfig, model: String, catalog: AIModelCatalog) -> Bool { + guard let descriptor = AIProviderRegistry.shared.descriptor(for: config.type.rawValue) else { return false } + return descriptor.supportsImages(fetched: catalog.fetchedInfo(providerID: config.id, modelID: model)) } func sendWithContext(prompt: String) { @@ -371,7 +377,7 @@ final class AIChatViewModel: ObservableObject { let results = await withTaskGroup(of: (UUID, [String]?).self) { group in for config in pending { let apiKey: String? - switch config.type.authStyle { + switch config.authStyle { case .apiKey, .optionalApiKey: apiKey = services.aiKeyStorage.loadAPIKey(for: config.id) case .oauth, .none: @@ -381,7 +387,7 @@ final class AIChatViewModel: ObservableObject { let transport = await AIProviderFactory.createProvider(for: config, apiKey: apiKey) do { let models = try await transport.fetchAvailableModels() - AIModelCatalog.shared.store(providerTypeID: config.type.rawValue, models: models) + AIModelCatalog.shared.store(providerID: config.id, models: models) return (config.id, models.map(\.id)) } catch is CancellationError { return (config.id, nil) diff --git a/TablePro/Views/Settings/AIProviderDetailSheet.swift b/TablePro/Views/Settings/AIProviderDetailSheet.swift index 170253a6a6..007243d756 100644 --- a/TablePro/Views/Settings/AIProviderDetailSheet.swift +++ b/TablePro/Views/Settings/AIProviderDetailSheet.swift @@ -14,9 +14,13 @@ struct AIProviderDetailSheet: View { let onDelete: (() -> Void)? let onCancel: () -> Void + private let savedEndpoint: String + private let savedAPIKey: String + @State private var draft: AIProviderConfig @State private var apiKey: String - @State private var fetchedModels: [String] = [] + @State private var fetched: [AIModelInfo] = [] + @State private var fetchedListIsCurrent = false @State private var isFetchingModels = false @State private var modelFetchError: String? @State private var modelFetchTask: Task? @@ -50,6 +54,8 @@ struct AIProviderDetailSheet: View { onDelete: (() -> Void)? = nil, onCancel: @escaping () -> Void ) { + self.savedEndpoint = provider.endpoint + self.savedAPIKey = initialAPIKey self._draft = State(initialValue: provider) self._apiKey = State(initialValue: initialAPIKey) self.isNew = isNew @@ -82,6 +88,7 @@ struct AIProviderDetailSheet: View { ToolbarItem(placement: .confirmationAction) { Button(String(localized: "Save")) { cancelTasks() + commitModelList() onSave(normalizedDraft, apiKey) } .keyboardShortcut(.defaultAction) @@ -131,13 +138,18 @@ struct AIProviderDetailSheet: View { private var navigationTitle: String { if isNew { - return String(format: String(localized: "Add %@"), draft.type.displayName) + return String(format: String(localized: "Add %@"), draft.kindName) } return draft.displayName } + private var needsModelChoice: Bool { + AIProviderDraftRules.needsModelChoice(model: draft.model, fetched: fetched) + } + private var isSaveEnabled: Bool { - switch draft.type.authStyle { + guard !needsModelChoice else { return false } + switch draft.authStyle { case .apiKey: return !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty case .optionalApiKey, .oauth, .none: @@ -148,6 +160,7 @@ struct AIProviderDetailSheet: View { private var normalizedDraft: AIProviderConfig { var provider = draft provider.model = draft.model.trimmingCharacters(in: .whitespacesAndNewlines) + provider.endpoint = AIProviderDraftRules.endpoint(draft.endpoint, defaultEndpoint: draft.defaultEndpoint) return provider } @@ -155,7 +168,7 @@ struct AIProviderDetailSheet: View { @ViewBuilder private var authSection: some View { - switch draft.type.authStyle { + switch draft.authStyle { case .apiKey, .optionalApiKey: if draft.type == .cursor { cursorAuthSection @@ -239,7 +252,7 @@ struct AIProviderDetailSheet: View { Text("Test Connection") } } - .disabled(isTesting || (draft.type.authStyle == .apiKey && apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)) + .disabled(isTesting || (draft.authStyle == .apiKey && apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)) } if case .success = testResult { Label(String(localized: "Connection successful"), systemImage: "checkmark.circle.fill") @@ -675,7 +688,7 @@ struct AIProviderDetailSheet: View { TextField( String(localized: "Base URL"), text: $draft.endpoint, - prompt: Text(draft.type.defaultEndpoint) + prompt: Text(draft.defaultEndpoint) ) .onChange(of: draft.endpoint) { _ in scheduleFetchModels() @@ -717,8 +730,8 @@ struct AIProviderDetailSheet: View { AIModelListFetchGate.blocker( fetchesModelList: descriptor?.fetchesModelList == true, takesEndpoint: descriptor?.allowsEndpointConfiguration == true, - endpoint: draft.endpoint, - authStyle: draft.type.authStyle, + endpoint: normalizedDraft.endpoint, + authStyle: draft.authStyle, apiKey: apiKey ) } @@ -726,7 +739,7 @@ struct AIProviderDetailSheet: View { /// Ollama, llama.cpp, MLX and a keyless Custom server send no authorization header at all, so /// naming the key there would warn about something that is not happening. private var cleartextCaution: String { - guard draft.type.authStyle.usesAPIKey, + guard draft.authStyle.usesAPIKey, !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return String(localized: "Requests to this host are sent unencrypted over http.") @@ -735,7 +748,7 @@ struct AIProviderDetailSheet: View { } private var resolvedEndpoint: AIEndpoint? { - AIEndpoint(draft.endpoint, style: draft.type.endpointStyle) + AIEndpoint(normalizedDraft.endpoint, style: draft.type.endpointStyle) } private var resolvedChatURL: String? { @@ -774,8 +787,15 @@ struct AIProviderDetailSheet: View { descriptor?.curatedModels ?? [] } + private var fetchedModels: [String] { + fetched.map(\.id) + } + private var effortLevelsForCurrentModel: [ReasoningEffort] { - descriptor?.supportedEffortLevels(forModelID: draft.model) ?? [] + descriptor?.supportedEffortLevels( + forModelID: draft.model, + fetched: fetched.first(where: { $0.id == draft.model }) + ) ?? [] } private var showsReasoningPicker: Bool { @@ -860,6 +880,9 @@ struct AIProviderDetailSheet: View { } case .fetched(let id): draft.model = id + if let effort = draft.reasoningEffort, !effortLevelsForCurrentModel.contains(effort) { + draft.reasoningEffort = nil + } case .custom: if curatedModels.contains(where: { $0.id == draft.model }) || fetchedModels.contains(draft.model) { draft.model = "" @@ -908,6 +931,11 @@ struct AIProviderDetailSheet: View { .font(.caption) .foregroundStyle(.secondary) } + if needsModelChoice { + Text("Pick a model to save this provider.") + .font(.caption) + .foregroundStyle(.secondary) + } } // MARK: - Advanced @@ -1005,7 +1033,24 @@ struct AIProviderDetailSheet: View { } } + private func commitModelList() { + let update = AIProviderDraftRules.catalogUpdate( + listIsCurrent: fetchedListIsCurrent, + listIsEmpty: fetched.isEmpty, + connectionChanged: normalizedDraft.endpoint != savedEndpoint || apiKey != savedAPIKey + ) + switch update { + case .store: + AIModelCatalog.shared.store(providerID: draft.id, models: fetched) + case .remove: + AIModelCatalog.shared.remove(providerID: draft.id) + case .keep: + break + } + } + private func scheduleFetchModels() { + fetchedListIsCurrent = false modelFetchTask?.cancel() modelFetchTask = Task { try? await Task.sleep(nanoseconds: 500_000_000) @@ -1017,15 +1062,15 @@ struct AIProviderDetailSheet: View { private func fetchModels() { switch modelListBlocker { case .notFetchable: - fetchedModels = [] + fetched = [] + fetchedListIsCurrent = false modelFetchError = nil isFetchingModels = false - if draft.model.isEmpty, let first = curatedModels.first { - draft.model = first.id - } + selectInitialModel() return case .missingEndpoint, .missingAPIKey: - fetchedModels = [] + fetched = [] + fetchedListIsCurrent = false modelFetchError = nil isFetchingModels = false return @@ -1042,23 +1087,34 @@ struct AIProviderDetailSheet: View { do { let models = try await provider.fetchAvailableModels() guard !Task.isCancelled else { return } - AIModelCatalog.shared.store(providerTypeID: draft.type.rawValue, models: models) - fetchedModels = models.map(\.id) - if draft.model.isEmpty, let first = fetchedModels.first { - draft.model = first - } + fetched = models + fetchedListIsCurrent = true + selectInitialModel() isFetchingModels = false } catch { guard !Task.isCancelled else { return } + /// A list still on screen after the key or Base URL changed came from the old + /// ones, so it goes when the new ones fail. A list fetched with the current ones + /// stays: the server it describes has not changed. + if !fetchedListIsCurrent { + fetched = [] + } modelFetchError = error.localizedDescription isFetchingModels = false } } } + private func selectInitialModel() { + guard draft.model.isEmpty, + let initial = AIProviderDraftRules.initialModel(curated: curatedModels, fetched: fetched) + else { return } + draft.model = initial + } + func testProvider() { let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) - if draft.type.authStyle == .apiKey, trimmed.isEmpty { + if draft.authStyle == .apiKey, trimmed.isEmpty { testResult = .failure(String(localized: "API key is required")) return } diff --git a/TablePro/Views/Settings/AISettingsView.swift b/TablePro/Views/Settings/AISettingsView.swift index bdcfb93687..2ea2e58c82 100644 --- a/TablePro/Views/Settings/AISettingsView.swift +++ b/TablePro/Views/Settings/AISettingsView.swift @@ -12,7 +12,7 @@ struct AISettingsView: View { @Binding var settings: AISettings @State private var editingProviderID: UUID? - @State private var addingProviderType: AIProviderType? + @State private var addingProvider: AIProviderConfig? @State private var pendingDeleteID: UUID? @ObservedObject private var chatGPTCodexService = ChatGPTCodexService.shared @ObservedObject private var cursorAgentService = CursorAgentService.shared @@ -61,18 +61,18 @@ struct AISettingsView: View { } ) } - .sheet(item: $addingProviderType) { type in + .sheet(item: $addingProvider) { provider in AIProviderDetailSheet( - provider: makeNewProvider(type: type), + provider: provider, initialAPIKey: "", isNew: true, onSave: { saved, apiKey in saveProvider(saved, apiKey: apiKey, isNew: true) - addingProviderType = nil + addingProvider = nil }, onDelete: nil, onCancel: { - addingProviderType = nil + addingProvider = nil } ) } @@ -179,7 +179,7 @@ struct AISettingsView: View { } .frame(width: 14) - Image(systemName: provider.type.symbolName) + Image(systemName: provider.symbolName) .foregroundStyle(.secondary) .frame(width: 20) @@ -204,14 +204,21 @@ struct AISettingsView: View { Menu { ForEach(orderedAddableTypes, id: \.self) { type in Button { - addingProviderType = type + addingProvider = makeNewProvider(type: type) } label: { Label(type.displayName, systemImage: type.symbolName) } } + ForEach(AIProviderPreset.all) { preset in + Button { + addingProvider = AIProviderConfig(preset: preset) + } label: { + Label(preset.displayName, systemImage: preset.symbolName) + } + } Divider() Button { - addingProviderType = .custom + addingProvider = makeNewProvider(type: .custom) } label: { Label(String(localized: "Add Custom Provider…"), systemImage: AIProviderType.custom.symbolName) } @@ -343,10 +350,12 @@ struct AISettingsView: View { // MARK: - Status text private func statusText(for provider: AIProviderConfig) -> String { - switch provider.type.authStyle { + switch provider.authStyle { case .oauth: return oauthStatusText(for: provider.type) - case .apiKey, .optionalApiKey: + case .apiKey: + return apiKeyStatusText(for: provider) + case .optionalApiKey: if provider.type == .custom { return customStatusText(for: provider) } @@ -356,14 +365,12 @@ struct AISettingsView: View { if provider.type == .xai { return xaiStatusText(for: provider) } - return providersWithKey.contains(provider.id) - ? String(localized: "API key set") - : String(localized: "Not configured") + return apiKeyStatusText(for: provider) case .none: if provider.type == .claudeAgent { return claudeAgentStatusText() } - let endpoint = provider.endpoint.isEmpty ? provider.type.defaultEndpoint : provider.endpoint + let endpoint = provider.endpoint.isEmpty ? provider.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") @@ -372,6 +379,12 @@ struct AISettingsView: View { } } + private func apiKeyStatusText(for provider: AIProviderConfig) -> String { + providersWithKey.contains(provider.id) + ? String(localized: "API key set") + : String(localized: "Not configured") + } + private func oauthStatusText(for type: AIProviderType) -> String { switch OAuthProviderRegistry.service(for: type)?.oauthState ?? .signedOut { case .signedIn(let identity): @@ -436,7 +449,7 @@ struct AISettingsView: View { private func refreshKeyAvailability() { var ids: Set = [] - for provider in settings.providers where provider.type.authStyle.usesAPIKey { + for provider in settings.providers where provider.authStyle.usesAPIKey { if let key = AIKeyStorage.shared.loadAPIKey(for: provider.id), !key.isEmpty { ids.insert(provider.id) } @@ -460,7 +473,7 @@ struct AISettingsView: View { } private func saveProvider(_ provider: AIProviderConfig, apiKey: String, isNew: Bool) { - if provider.type.authStyle.usesAPIKey { + if provider.authStyle.usesAPIKey { AIKeyStorage.shared.saveAPIKey(apiKey, for: provider.id) } @@ -481,6 +494,7 @@ struct AISettingsView: View { private func removeProvider(_ id: UUID) { AIKeyStorage.shared.deleteAPIKey(for: id) AIProviderFactory.invalidateCache(for: id) + AIModelCatalog.shared.remove(providerID: id) settings.providers.removeAll { $0.id == id } if settings.activeProviderID == id { settings.activeProviderID = nil diff --git a/TableProTests/Core/AI/AIEndpointTests.swift b/TableProTests/Core/AI/AIEndpointTests.swift index ec76b8bc07..d26511eb54 100644 --- a/TableProTests/Core/AI/AIEndpointTests.swift +++ b/TableProTests/Core/AI/AIEndpointTests.swift @@ -22,7 +22,6 @@ struct AIEndpointTests { #expect(chatURL("https://api.openai.com", .chatCompletions) == "https://api.openai.com/v1/chat/completions") #expect(modelsURL("https://api.openai.com", .chatCompletions) == "https://api.openai.com/v1/models") #expect(chatURL("https://openrouter.ai/api", .chatCompletions) == "https://openrouter.ai/api/v1/chat/completions") - #expect(chatURL("https://router.requesty.ai", .chatCompletions) == "https://router.requesty.ai/v1/chat/completions") #expect(chatURL("https://opencode.ai/zen", .chatCompletions) == "https://opencode.ai/zen/v1/chat/completions") #expect(chatURL("http://localhost:8080", .chatCompletions) == "http://localhost:8080/v1/chat/completions") } diff --git a/TableProTests/Core/AI/AIModelCatalogTests.swift b/TableProTests/Core/AI/AIModelCatalogTests.swift index 94eb8e6769..5ff6975a51 100644 --- a/TableProTests/Core/AI/AIModelCatalogTests.swift +++ b/TableProTests/Core/AI/AIModelCatalogTests.swift @@ -8,63 +8,74 @@ import Foundation import Testing struct AIModelCatalogTests { - private let claude = AIProviderType.claude.rawValue + init() { + AIProviderRegistration.registerAll() + } + + private func claudeDescriptor() throws -> AIProviderDescriptor { + try #require(AIProviderRegistry.shared.descriptor(for: AIProviderType.claude.rawValue)) + } @Test("Live provider metadata wins over the static overlay") - func liveMetadataWins() { - let catalog = AIModelCatalog() + func liveMetadataWins() throws { let live = AIModelInfo( id: "claude-haiku-4-5", - displayName: "Claude Haiku 4.5", - maxOutputTokens: 64_000, reasoning: AIReasoningSupport(mode: .adaptive, effortLevels: [.low, .medium, .high, .xhigh]) ) - catalog.store(providerTypeID: claude, models: [live]) - - let resolved = catalog.resolve(providerTypeID: claude, modelID: "claude-haiku-4-5") - #expect(resolved.reasoning?.mode == .adaptive, "live metadata must override the offline table") - #expect(resolved.maxOutputTokens == 64_000) - #expect(resolved.displayName == "Claude Haiku 4.5") + let levels = try claudeDescriptor().supportedEffortLevels(forModelID: "claude-haiku-4-5", fetched: live) + #expect(levels == [.low, .medium, .high, .xhigh], "live metadata must override the offline table") } @Test("Without live metadata the overlay supplies the answer") - func overlayFallback() { + func overlayFallback() throws { + let levels = try claudeDescriptor().supportedEffortLevels(forModelID: "claude-haiku-4-5", fetched: nil) + #expect(levels == [.low, .medium, .high]) + } + + @Test("Storing an empty list never erases what is already known") + func emptyStoreIsIgnored() { let catalog = AIModelCatalog() - let resolved = catalog.resolve(providerTypeID: claude, modelID: "claude-haiku-4-5") - #expect(resolved.reasoning?.mode == .budgeted, "Haiku 4.5 has no adaptive thinking") - #expect(resolved.reasoning?.effortLevels == [.low, .medium, .high]) + let provider = UUID() + let live = AIModelInfo(id: "m", reasoning: AIReasoningSupport(mode: .adaptive, effortLevels: [.high])) + catalog.store(providerID: provider, models: [live]) + catalog.store(providerID: provider, models: []) + #expect(catalog.fetchedInfo(providerID: provider, modelID: "m") != nil) } - @Test("An unknown provider and model still resolve to a usable value") - func unknownResolvesToPlainInfo() { + /// Two custom providers are two servers. Keyed by provider type, a list fetched from one + /// replaced what was known about the other. + @Test("Two providers of the same type keep separate lists") + func listsAreKeptPerProvider() { let catalog = AIModelCatalog() - let resolved = catalog.resolve(providerTypeID: "nonexistent", modelID: "some-model") - #expect(resolved.id == "some-model") - #expect(resolved.reasoning == nil) + let router = UUID() + let local = UUID() + catalog.store(providerID: router, models: [AIModelInfo(id: "shared", modalities: [.text])]) + catalog.store(providerID: local, models: [AIModelInfo(id: "shared", modalities: [.text, .image])]) + + #expect(catalog.fetchedInfo(providerID: router, modelID: "shared")?.supportsImages == false) + #expect(catalog.fetchedInfo(providerID: local, modelID: "shared")?.supportsImages == true) } - @Test("Storing an empty list never erases what is already known") - func emptyStoreIsIgnored() { + @Test("A newer list replaces the older one for the same provider") + func newerListReplacesTheOlder() { let catalog = AIModelCatalog() - let live = AIModelInfo(id: "m", reasoning: AIReasoningSupport(mode: .adaptive, effortLevels: [.high])) - catalog.store(providerTypeID: claude, models: [live]) - catalog.store(providerTypeID: claude, models: []) - #expect(catalog.fetchedInfo(providerTypeID: claude, modelID: "m") != nil) + let provider = UUID() + catalog.store(providerID: provider, models: [AIModelInfo(id: "old")]) + catalog.store(providerID: provider, models: [AIModelInfo(id: "new")]) + #expect(catalog.fetchedInfo(providerID: provider, modelID: "old") == nil) + #expect(catalog.fetchedInfo(providerID: provider, modelID: "new") != nil) } - @Test("Merging fills only the fields the provider left unknown") - func mergingPrefersLiveFields() { - let live = AIModelInfo(id: "m", displayName: nil, maxOutputTokens: 100) - let fallback = AIModelInfo( - id: "m", - displayName: "Fallback", - maxOutputTokens: 999, - reasoning: AIReasoningSupport(mode: .budgeted, effortLevels: [.low]) - ) - let merged = live.merging(fallback: fallback) - #expect(merged.maxOutputTokens == 100, "a live value must not be overwritten") - #expect(merged.displayName == "Fallback", "an unknown live field takes the fallback") - #expect(merged.reasoning?.mode == .budgeted) + @Test("A provider nothing was fetched for has no entry, and a removed one loses its own") + func unknownAndRemovedProvidersHaveNoEntry() { + let catalog = AIModelCatalog() + let provider = UUID() + #expect(catalog.fetchedInfo(providerID: provider, modelID: "m") == nil) + #expect(catalog.fetchedInfo(providerID: nil, modelID: "m") == nil) + + catalog.store(providerID: provider, models: [AIModelInfo(id: "m")]) + catalog.remove(providerID: provider) + #expect(catalog.fetchedInfo(providerID: provider, modelID: "m") == nil) } @Test("Anthropic capability payloads decode into reasoning support") diff --git a/TableProTests/Core/AI/AIProviderCapabilitiesTests.swift b/TableProTests/Core/AI/AIProviderCapabilitiesTests.swift index 3c1b68b511..9c28638cef 100644 --- a/TableProTests/Core/AI/AIProviderCapabilitiesTests.swift +++ b/TableProTests/Core/AI/AIProviderCapabilitiesTests.swift @@ -39,7 +39,7 @@ struct AIProviderCapabilitiesTests { @Test("HTTP API-key providers accept max output tokens, a configurable endpoint, and model fetch") func standardHTTPProviders() { - for type in [AIProviderType.openAI, .claude, .gemini, .xai, .openRouter, .requesty, .ollama] { + for type in [AIProviderType.openAI, .claude, .gemini, .xai, .openRouter, .ollama] { let provider = descriptor(type) #expect(provider?.allowsMaxOutputTokens == true, "\(type.rawValue) should accept max output tokens") #expect(provider?.allowsEndpointConfiguration == true, "\(type.rawValue) should allow endpoint config") @@ -54,6 +54,54 @@ struct AIProviderCapabilitiesTests { } } + @Test("The OpenAI-compatible family is registered from one list, on one transport") + func openAICompatibleFamilySharesATransport() { + for type in AIProviderType.openAICompatibleFamily { + let config = AIProviderConfig(type: type, model: "m", endpoint: "http://localhost:1") + #expect( + descriptor(type)?.makeProvider(config, nil) is OpenAICompatibleProvider, + "\(type.rawValue) must build the OpenAI-compatible transport" + ) + } + } + + /// Neither transport sends an effort: Gemini builds no thinking configuration, and Ollama's + /// native route takes `think` rather than `reasoning_effort`. A picker there changed nothing. + @Test("A provider whose transport ignores reasoning effort offers no effort picker") + func noEffortPickerWhereTheTransportIgnoresIt() { + for type in [AIProviderType.gemini, .ollama] { + #expect(descriptor(type)?.supportsReasoning == false, "\(type.rawValue) must not offer reasoning") + #expect(descriptor(type)?.supportedEffortLevels(forModelID: "any-model").isEmpty == true) + } + } + + @Test("The provider's own model list narrows reasoning and images per model") + func fetchedModelInfoNarrowsTheEnvelope() throws { + let router = try #require(descriptor(.openRouter)) + let textOnly = AIModelInfo(id: "m", modalities: [.text], reasoning: .unsupported) + #expect(router.supportsImages(fetched: textOnly) == false) + #expect(router.supportedEffortLevels(forModelID: "m", fetched: textOnly).isEmpty) + + let capable = AIModelInfo( + id: "m", + modalities: [.text, .image], + reasoning: AIReasoningSupport(mode: .effortOnly, effortLevels: [.low, .medium, .high]) + ) + #expect(router.supportsImages(fetched: capable)) + #expect(router.supportedEffortLevels(forModelID: "m", fetched: capable) == [.low, .medium, .high]) + } + + /// A plain OpenAI model list carries an id and nothing else. Reading that silence as "text + /// only" would have closed images and reasoning for every model on such a server. + @Test("A model list that says nothing about a model leaves the envelope open") + func silentModelInfoLeavesTheEnvelopeOpen() throws { + let router = try #require(descriptor(.openRouter)) + let silent = AIModelInfo(id: "m") + #expect(silent.supportsImages == nil) + #expect(router.supportsImages(fetched: silent)) + #expect(router.supportedEffortLevels(forModelID: "m", fetched: silent) == [.low, .medium, .high]) + } + @Test("Only the custom provider allows the name field") func nameFieldOnlyForCustom() { #expect(descriptor(.custom)?.allowsNameConfiguration == true) diff --git a/TableProTests/Core/AI/AIProviderDraftRulesTests.swift b/TableProTests/Core/AI/AIProviderDraftRulesTests.swift new file mode 100644 index 0000000000..aa81bdf674 --- /dev/null +++ b/TableProTests/Core/AI/AIProviderDraftRulesTests.swift @@ -0,0 +1,122 @@ +// +// AIProviderDraftRulesTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +struct AIProviderDraftRulesTests { + private func models(_ ids: [String]) -> [AIModelInfo] { + ids.map { AIModelInfo(id: $0) } + } + + /// The reported defect: a router answers with several hundred models sorted by name, and the + /// sheet put a new provider on the first of them without being asked. + @Test("A fetched list with several models picks none of them") + func severalFetchedModelsPickNone() { + let fetched = models(["alibaba/qwen-max", "anthropic/claude-sonnet-5", "openai/gpt-5.5"]) + #expect(AIProviderDraftRules.initialModel(curated: [], fetched: fetched) == nil) + } + + @Test("A server offering one model starts on it") + func singleFetchedModelIsPicked() { + #expect(AIProviderDraftRules.initialModel(curated: [], fetched: models(["qwen3:8b"])) == "qwen3:8b") + } + + @Test("The first curated model wins over anything fetched") + func curatedWins() { + let curated = [CuratedModel(id: "claude-opus-5", displayName: "Claude Opus 5")] + let fetched = models(["claude-3-haiku", "claude-opus-5"]) + #expect(AIProviderDraftRules.initialModel(curated: curated, fetched: fetched) == "claude-opus-5") + #expect(AIProviderDraftRules.initialModel(curated: curated, fetched: []) == "claude-opus-5") + } + + @Test("The model a provider marks as its default is picked from a longer list") + func providerDefaultIsPicked() { + let fetched = [ + AIModelInfo(id: "gpt-a"), + AIModelInfo(id: "gpt-b", isProviderDefault: true), + AIModelInfo(id: "gpt-c") + ] + #expect(AIProviderDraftRules.initialModel(curated: [], fetched: fetched) == "gpt-b") + } + + @Test("With nothing curated and nothing fetched there is no model to pick") + func nothingToPick() { + #expect(AIProviderDraftRules.initialModel(curated: [], fetched: []) == nil) + } + + @Test("Save waits for a model while there is a list to pick from") + func saveWaitsForAModelWhenThereIsAList() { + let fetched = models(["alibaba/qwen-max", "openai/gpt-5.5"]) + #expect(AIProviderDraftRules.needsModelChoice(model: "", fetched: fetched)) + #expect(AIProviderDraftRules.needsModelChoice(model: " ", fetched: fetched)) + #expect(!AIProviderDraftRules.needsModelChoice(model: "openai/gpt-5.5", fetched: fetched)) + #expect(!AIProviderDraftRules.needsModelChoice(model: "typed-by-hand", fetched: fetched)) + } + + /// A provider whose list is blocked, failed, or waits on a sign-in has nothing to pick from, and + /// requiring a model there would leave Save dimmed with no way forward. + @Test("Save does not wait for a model when no list is available") + func saveDoesNotWaitWithoutAList() { + #expect(!AIProviderDraftRules.needsModelChoice(model: "", fetched: [])) + } + + /// The reported defect: clearing the field saved an empty endpoint under a placeholder that + /// showed the default, and every request failed until the next launch. + @Test("A cleared Base URL saves as the default the placeholder shows") + func clearedEndpointSavesAsTheDefault() { + let fallback = AIProviderPreset.requesty.endpoint + #expect(AIProviderDraftRules.endpoint("", defaultEndpoint: fallback) == fallback) + #expect(AIProviderDraftRules.endpoint(" \n", defaultEndpoint: fallback) == fallback) + } + + @Test("A typed Base URL is kept, without its surrounding whitespace") + func typedEndpointIsKept() { + #expect( + AIProviderDraftRules.endpoint(" https://router.eu.requesty.ai ", defaultEndpoint: "https://router.requesty.ai") + == "https://router.eu.requesty.ai" + ) + } + + @Test("A custom provider with no default keeps an empty Base URL empty") + func noDefaultStaysEmpty() { + #expect(AIProviderDraftRules.endpoint("", defaultEndpoint: "").isEmpty) + } + + @Test("Save stores a list that loaded for the draft's key and Base URL") + func currentListIsStored() { + #expect( + AIProviderDraftRules.catalogUpdate(listIsCurrent: true, listIsEmpty: false, connectionChanged: true) + == .store + ) + } + + @Test("Save drops the old list when the server answered with no models") + func currentEmptyListIsRemoved() { + #expect( + AIProviderDraftRules.catalogUpdate(listIsCurrent: true, listIsEmpty: true, connectionChanged: false) + == .remove + ) + } + + /// The list on record was fetched with the old key or Base URL, so it describes a server the + /// saved provider no longer points at. + @Test("Save drops the old list when the key or Base URL changed and the new one did not load") + func staleListIsRemovedWhenTheConnectionChanged() { + #expect( + AIProviderDraftRules.catalogUpdate(listIsCurrent: false, listIsEmpty: false, connectionChanged: true) + == .remove + ) + } + + @Test("Save keeps the list on record when nothing about the connection changed") + func untouchedConnectionKeepsItsList() { + #expect( + AIProviderDraftRules.catalogUpdate(listIsCurrent: false, listIsEmpty: true, connectionChanged: false) + == .keep + ) + } +} diff --git a/TableProTests/Core/AI/AIProviderModelFetchTests.swift b/TableProTests/Core/AI/AIProviderModelFetchTests.swift index 94095e9b1c..97dfb1587c 100644 --- a/TableProTests/Core/AI/AIProviderModelFetchTests.swift +++ b/TableProTests/Core/AI/AIProviderModelFetchTests.swift @@ -131,6 +131,23 @@ struct AIProviderModelFetchTests { #expect(models.isEmpty) } + @Test("An OpenAI-compatible model list comes back sorted by id, with what the server said about each model") + func openAICompatibleListKeepsModelMetadata() async throws { + StubModelListProtocol.respond(status: 200, body: #""" + {"data":[ + {"id":"openai/gpt-5.5","supports_vision":true,"supports_reasoning":true}, + {"id":"alibaba/qwen-max","supports_vision":false,"supports_reasoning":false}, + {"object":"model"} + ]} + """#) + let models = try await compatibleProvider(.custom, endpoint: "https://router.requesty.ai") + .fetchAvailableModels() + #expect(models.map(\.id) == ["alibaba/qwen-max", "openai/gpt-5.5"]) + #expect(models.first?.supportsImages == false) + #expect(models.first?.reasoning == .unsupported) + #expect(models.last?.supportsImages == true) + } + /// An empty picker with no error reads as "this server has no models", which is not what a /// gateway answering 200 with the wrong shape is saying. @Test("A 200 whose JSON has no model array is reported, not read as an empty list") diff --git a/TableProTests/Core/AI/AIProviderPresetTests.swift b/TableProTests/Core/AI/AIProviderPresetTests.swift new file mode 100644 index 0000000000..a13b06ec59 --- /dev/null +++ b/TableProTests/Core/AI/AIProviderPresetTests.swift @@ -0,0 +1,165 @@ +// +// AIProviderPresetTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +struct AIProviderPresetTests { + init() { + AIProviderRegistration.registerAll() + } + + @Test("Requesty needs a key, and its default Base URL resolves to its chat completions route") + func requestyPreset() throws { + let preset = AIProviderPreset.requesty + #expect(preset.authStyle == .apiKey) + #expect(preset.rejectsBadKeyWithForbidden) + + let config = AIProviderConfig(preset: preset) + let style = config.type.endpointStyle + #expect(style == .chatCompletions) + #expect( + AIEndpoint(config.endpoint, style: style)?.chatURL(model: "openai/gpt-5.5", style: style)?.absoluteString + == "https://router.requesty.ai/v1/chat/completions" + ) + #expect( + AIEndpoint(config.endpoint, style: style)?.url(appending: style.modelsResource)?.absoluteString + == "https://router.requesty.ai/v1/models" + ) + } + + @Test("Every preset has a unique id and an https endpoint the transport can resolve") + func presetsAreWellFormed() { + #expect(Set(AIProviderPreset.all.map(\.id)).count == AIProviderPreset.all.count) + for preset in AIProviderPreset.all { + #expect(AIProviderPreset.preset(withID: preset.id) == preset) + #expect(preset.endpoint.hasPrefix("https://"), "\(preset.id) must default to https") + #expect(AIEndpoint(preset.endpoint, style: .chatCompletions) != nil, "\(preset.id) endpoint must resolve") + } + #expect(AIProviderPreset.preset(withID: nil) == nil) + #expect(AIProviderPreset.preset(withID: "no-such-vendor") == nil) + } + + @Test("A provider added from a preset is a custom provider that reads as the vendor") + func presetConfigReadsAsTheVendor() { + let config = AIProviderConfig(preset: .requesty) + #expect(config.type == .custom) + #expect(config.presetID == "requesty") + #expect(config.preset == .requesty) + #expect(config.name == "Requesty") + #expect(config.endpoint == "https://router.requesty.ai") + #expect(config.kindName == "Requesty") + #expect(config.symbolName == AIProviderPreset.requesty.symbolName) + #expect(config.authStyle == .apiKey) + } + + @Test("A preset provider whose name was cleared still shows the vendor, not Custom") + func clearedNameFallsBackToTheVendor() { + var config = AIProviderConfig(preset: .requesty) + config.name = "" + #expect(config.displayName == "Requesty") + } + + @Test("A plain custom provider keeps its own name, icon and optional key") + func plainCustomIsUnchanged() { + let config = AIProviderConfig(type: .custom, endpoint: "https://api.z.ai/api/paas/v4") + #expect(config.preset == nil) + #expect(config.kindName == AIProviderType.custom.displayName) + #expect(config.symbolName == AIProviderType.custom.symbolName) + #expect(config.authStyle == .optionalApiKey) + #expect(config.defaultEndpoint.isEmpty) + } + + @Test("A preset id on a provider that is not custom changes nothing") + func presetOnlyAppliesToCustom() { + let config = AIProviderConfig(type: .openRouter, presetID: "requesty") + #expect(config.preset == nil) + #expect(config.endpoint == AIProviderType.openRouter.defaultEndpoint) + #expect(config.kindName == "OpenRouter") + } + + /// The point of a preset over a new provider type: the stored type is one every released + /// build already decodes, so settings synced to an older build keep all their providers. + @Test("A preset provider is stored under the custom type, which older builds decode") + func storedTypeIsCustom() throws { + let data = try JSONEncoder().encode(AIProviderConfig(preset: .requesty)) + let object = try JSONSerialization.jsonObject(with: data) + let json = try #require(object as? [String: Any]) + #expect(json["type"] as? String == "custom") + #expect(json["presetID"] as? String == "requesty") + #expect(json["name"] as? String == "Requesty") + #expect(json["endpoint"] as? String == "https://router.requesty.ai") + } + + @Test("A preset provider survives an encode and decode round trip") + func roundTrips() throws { + var config = AIProviderConfig(preset: .requesty) + config.model = "anthropic/claude-sonnet-5" + config.endpoint = "https://router.eu.requesty.ai" + let decoded = try JSONDecoder().decode(AIProviderConfig.self, from: JSONEncoder().encode(config)) + #expect(decoded == config) + #expect(decoded.authStyle == .apiKey) + } + + @Test("A stored preset provider with no endpoint decodes to the preset's default") + func emptyEndpointDecodesToThePresetDefault() throws { + let json = #"{"id":"11111111-2222-3333-4444-555555555555","type":"custom","presetID":"requesty","endpoint":""}"# + let decoded = try JSONDecoder().decode(AIProviderConfig.self, from: Data(json.utf8)) + #expect(decoded.endpoint == "https://router.requesty.ai") + } + + @Test("A custom provider saved before presets existed decodes with none") + func legacyCustomDecodes() throws { + let json = #"{"id":"11111111-2222-3333-4444-555555555555","type":"custom","name":"vLLM","endpoint":"http://gpu:8000"}"# + let decoded = try JSONDecoder().decode(AIProviderConfig.self, from: Data(json.utf8)) + #expect(decoded.presetID == nil) + #expect(decoded.authStyle == .optionalApiKey) + #expect(decoded.displayName == "vLLM") + } + + /// A preset this build has never heard of comes from a newer build over sync. It has to keep + /// working as the custom provider it is stored as, and keep its id for the build that knows it. + @Test("A preset id this build does not know behaves as custom and is written back unchanged") + func unknownPresetIsKept() throws { + let json = #""" + {"id":"11111111-2222-3333-4444-555555555555","type":"custom","presetID":"vendor-from-the-future", + "name":"Future","endpoint":"https://api.future.example"} + """# + let decoded = try JSONDecoder().decode(AIProviderConfig.self, from: Data(json.utf8)) + #expect(decoded.preset == nil) + #expect(decoded.authStyle == .optionalApiKey) + #expect(decoded.displayName == "Future") + #expect(decoded.endpoint == "https://api.future.example") + + let reencoded = try JSONSerialization.jsonObject(with: JSONEncoder().encode(decoded)) as? [String: Any] + #expect(reencoded?["presetID"] as? String == "vendor-from-the-future") + } + + @Test("A preset provider builds the OpenAI-compatible transport") + func buildsTheSharedTransport() throws { + let descriptor = try #require(AIProviderRegistry.shared.descriptor(for: AIProviderType.custom.rawValue)) + let config = AIProviderConfig(preset: .requesty) + #expect(descriptor.makeProvider(config, "key") is OpenAICompatibleProvider) + #expect(AIProviderFactory.makeUncachedProvider(for: config, apiKey: "key") is OpenAICompatibleProvider) + } + + @Test("A preset that requires a key blocks the model list until one is typed") + func modelListWaitsForTheKey() { + let config = AIProviderConfig(preset: .requesty) + #expect( + AIModelListFetchGate.blocker( + fetchesModelList: true, takesEndpoint: true, + endpoint: config.endpoint, authStyle: config.authStyle, apiKey: "" + ) == .missingAPIKey + ) + #expect( + AIModelListFetchGate.blocker( + fetchesModelList: true, takesEndpoint: true, + endpoint: config.endpoint, authStyle: config.authStyle, apiKey: "sk-live" + ) == nil + ) + } +} diff --git a/TableProTests/Core/AI/AnthropicProviderEncodingTests.swift b/TableProTests/Core/AI/AnthropicProviderEncodingTests.swift index cdea57f739..6852a36709 100644 --- a/TableProTests/Core/AI/AnthropicProviderEncodingTests.swift +++ b/TableProTests/Core/AI/AnthropicProviderEncodingTests.swift @@ -75,17 +75,33 @@ struct AnthropicProviderEncodingTests { private func body( model: String, effort: ReasoningEffort?, - maxTokens: Int = 32_768 + maxTokens: Int = 32_768, + liveReasoning: AIReasoningSupport? = nil ) throws -> [String: Any] { try AnthropicProvider.makeRequestBody( turns: [ChatTurnWire(role: .user, blocks: [.text("hi")])], options: ChatTransportOptions(model: model), effort: effort, maxTokens: maxTokens, - stream: true + stream: true, + liveReasoning: liveReasoning ) } + /// Haiku 4.5 is budgeted in the offline table. A model list that reports adaptive thinking for + /// it has to win, or a capability Anthropic adds later is ignored until the table is edited. + @Test("What the provider's model list says about reasoning wins over the offline table") + func liveReasoningWinsOverTheOfflineTable() throws { + let offline = try body(model: "claude-haiku-4-5", effort: .high) + #expect((offline["thinking"] as? [String: Any])?["type"] as? String == "enabled") + #expect(offline["output_config"] == nil) + + let live = AIReasoningSupport(mode: .adaptive, effortLevels: [.low, .medium, .high]) + let encoded = try body(model: "claude-haiku-4-5", effort: .high, liveReasoning: live) + #expect((encoded["thinking"] as? [String: Any])?["type"] as? String == "adaptive") + #expect((encoded["output_config"] as? [String: Any])?["effort"] as? String == "high") + } + @Test("Effort travels in output_config, never inside thinking (#2031)") func effortIsNotNestedInThinking() throws { let encoded = try body(model: "claude-opus-4-7", effort: .medium) diff --git a/TableProTests/Core/AI/ClaudeAgentProviderTests.swift b/TableProTests/Core/AI/ClaudeAgentProviderTests.swift index 67514cc184..0df630ac06 100644 --- a/TableProTests/Core/AI/ClaudeAgentProviderTests.swift +++ b/TableProTests/Core/AI/ClaudeAgentProviderTests.swift @@ -257,7 +257,7 @@ struct ClaudeAgentRegistrationTests { let descriptor = try #require( AIProviderRegistry.shared.descriptor(for: AIProviderType.claudeAgent.rawValue) ) - #expect(descriptor.displayName == "Claude Agent") + #expect(AIProviderType.claudeAgent.displayName == "Claude Agent") #expect(!descriptor.allowsEndpointConfiguration) #expect(descriptor.curatedModels.map(\.id) == ["opus", "sonnet", "haiku"]) diff --git a/TableProTests/Core/AI/LocalProviderRegistrationTests.swift b/TableProTests/Core/AI/LocalProviderRegistrationTests.swift index 4dd77045ea..a3961b8cfe 100644 --- a/TableProTests/Core/AI/LocalProviderRegistrationTests.swift +++ b/TableProTests/Core/AI/LocalProviderRegistrationTests.swift @@ -55,7 +55,8 @@ struct LocalProviderRegistrationTests { let provider = descriptor(for: type) #expect(provider?.supportsReasoning == true) #expect(provider?.supportsImages == true) - #expect(provider?.supportsImages(forModelID: "some-unfetched-local-model") == true) + #expect(provider?.supportsImages(fetched: nil) == true) + #expect(provider?.supportsImages(fetched: AIModelInfo(id: "text-only", modalities: [.text])) == false) #expect(provider?.supportedEffortLevels(forModelID: "some-unfetched-local-model").isEmpty == false) } } diff --git a/TableProTests/Core/AI/OpenAICompatibleModelDecodingTests.swift b/TableProTests/Core/AI/OpenAICompatibleModelDecodingTests.swift new file mode 100644 index 0000000000..c452f431d0 --- /dev/null +++ b/TableProTests/Core/AI/OpenAICompatibleModelDecodingTests.swift @@ -0,0 +1,85 @@ +// +// OpenAICompatibleModelDecodingTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +struct OpenAICompatibleModelDecodingTests { + /// The shape of Requesty's /v1/models, trimmed to the fields that are read. + private func requestyModel(vision: Bool, reasoning: Bool) -> [String: Any] { + [ + "id": "zai/glm-5.3-flash", + "object": "model", + "max_output_tokens": 128_000, + "context_window": 1_000_000, + "supports_vision": vision, + "supports_reasoning": reasoning, + "supports_tool_calling": true + ] + } + + /// The shape of OpenRouter's /api/v1/models, trimmed the same way. + private func openRouterModel(inputs: [String], parameters: [String]) -> [String: Any] { + [ + "id": "inclusionai/ling-3.1-flash", + "name": "inclusionAI: Ling 3.1 Flash", + "context_length": 262_144, + "architecture": ["input_modalities": inputs, "output_modalities": ["text"]], + "top_provider": ["context_length": 262_144, "max_completion_tokens": 32_768], + "supported_parameters": parameters + ] + } + + @Test("Requesty's flags decode into images, reasoning and token limits") + func requestyShape() throws { + let capable = try #require(OpenAICompatibleProvider.decodeModel(requestyModel(vision: true, reasoning: true))) + #expect(capable.id == "zai/glm-5.3-flash") + #expect(capable.supportsImages == true) + #expect(capable.reasoning?.effortLevels == [.low, .medium, .high]) + #expect(capable.reasoning?.sendsEffortParameter == true) + #expect(capable.contextWindow == 1_000_000) + #expect(capable.maxOutputTokens == 128_000) + + let plain = try #require(OpenAICompatibleProvider.decodeModel(requestyModel(vision: false, reasoning: false))) + #expect(plain.supportsImages == false) + #expect(plain.reasoning == .unsupported) + } + + @Test("OpenRouter's architecture and supported parameters decode the same way") + func openRouterShape() throws { + let capable = try #require( + OpenAICompatibleProvider.decodeModel( + openRouterModel(inputs: ["text", "image"], parameters: ["max_tokens", "reasoning", "tools"]) + ) + ) + #expect(capable.supportsImages == true) + #expect(capable.reasoning?.sendsEffortParameter == true) + #expect(capable.contextWindow == 262_144) + #expect(capable.maxOutputTokens == 32_768) + + let plain = try #require( + OpenAICompatibleProvider.decodeModel(openRouterModel(inputs: ["text"], parameters: ["max_tokens", "tools"])) + ) + #expect(plain.supportsImages == false) + #expect(plain.reasoning == .unsupported) + } + + /// A plain OpenAI model list, and every local server, answers with an id and little else. + @Test("A model with only an id claims nothing about images or reasoning") + func plainShapeStaysUnknown() throws { + let model = try #require(OpenAICompatibleProvider.decodeModel(["id": "glm-4.6", "object": "model"])) + #expect(model.supportsImages == nil) + #expect(model.reasoning == nil) + #expect(model.contextWindow == nil) + #expect(model.maxOutputTokens == nil) + } + + @Test("An entry with no usable id is skipped") + func missingIDIsSkipped() { + #expect(OpenAICompatibleProvider.decodeModel(["object": "model"]) == nil) + #expect(OpenAICompatibleProvider.decodeModel(["id": ""]) == nil) + } +} diff --git a/TableProTests/Core/AI/OpenAICompatibleProviderConnectionTests.swift b/TableProTests/Core/AI/OpenAICompatibleProviderConnectionTests.swift index b84c7ade89..f28b644773 100644 --- a/TableProTests/Core/AI/OpenAICompatibleProviderConnectionTests.swift +++ b/TableProTests/Core/AI/OpenAICompatibleProviderConnectionTests.swift @@ -60,7 +60,10 @@ private final class StubConnectionProtocol: URLProtocol, @unchecked Sendable { @Suite("OpenAICompatibleProvider connection test", .serialized) struct OpenAICompatibleProviderConnectionTests { - private func makeProvider(endpoint: String) -> OpenAICompatibleProvider { + private func makeProvider( + endpoint: String, + treatsForbiddenAsAuthFailure: Bool = false + ) -> OpenAICompatibleProvider { let config = URLSessionConfiguration.ephemeral config.protocolClasses = [StubConnectionProtocol.self] return OpenAICompatibleProvider( @@ -68,10 +71,99 @@ struct OpenAICompatibleProviderConnectionTests { apiKey: "key", providerType: .custom, model: "glm-4.6", + treatsForbiddenAsAuthFailure: treatsForbiddenAsAuthFailure, session: URLSession(configuration: config) ) } + private func thrownError(_ body: () async throws -> Void) async -> AIProviderError? { + do { + try await body() + return nil + } catch { + return error as? AIProviderError + } + } + + private func isAuthenticationFailure(_ error: AIProviderError?) -> Bool { + if case .authenticationFailed = error { return true } + return false + } + + private static let requestyBadKeyBody = #"{"error":{"origin":"router","message":"Invalid authorization token"}}"# + + /// Requesty answers a wrong key with 403. Read as a server error, the sheet said "Server error + /// (403)" and the chat offered to retry a request that could never succeed. + @Test("A 403 from a server that rejects bad keys that way is an authentication failure") + func forbiddenIsAnAuthFailureForAPresetThatSaysSo() async { + StubConnectionProtocol.respond(status: 403, body: Self.requestyBadKeyBody) + let provider = makeProvider(endpoint: "https://router.requesty.ai", treatsForbiddenAsAuthFailure: true) + let error = await thrownError { _ = try await provider.testConnection() } + #expect(isAuthenticationFailure(error)) + #expect(error?.isRetryable == false) + } + + @Test("A 403 from any other server stays a server error") + func forbiddenStaysAServerErrorByDefault() async { + StubConnectionProtocol.respond(status: 403, body: #"{"error":{"message":"region not supported"}}"#) + let error = await thrownError { _ = try await makeProvider(endpoint: "https://host/v1").testConnection() } + #expect(error != nil) + #expect(!isAuthenticationFailure(error)) + } + + @Test("A 403 on the model list is an authentication failure for such a server") + func forbiddenModelListIsAnAuthFailure() async { + StubConnectionProtocol.respond(status: 403, body: Self.requestyBadKeyBody) + let provider = makeProvider(endpoint: "https://router.requesty.ai", treatsForbiddenAsAuthFailure: true) + let error = await thrownError { _ = try await provider.fetchAvailableModels() } + #expect(isAuthenticationFailure(error)) + } + + @Test("A 403 on a chat turn is an authentication failure for such a server, so it is not retried") + func forbiddenChatTurnIsAnAuthFailure() async { + StubConnectionProtocol.respond(status: 403, body: Self.requestyBadKeyBody) + let provider = makeProvider(endpoint: "https://router.requesty.ai", treatsForbiddenAsAuthFailure: true) + let error = await thrownError { + let stream = provider.streamChat( + turns: [ChatTurnWire(role: .user, blocks: [.text("hi")])], + options: ChatTransportOptions(model: "glm-4.6") + ) + for try await _ in stream {} + } + #expect(isAuthenticationFailure(error)) + #expect(error?.isRetryable == false) + } + + @Test("A provider built from the Requesty preset reads a 403 as a rejected key") + func presetProviderCarriesTheRule() async { + StubConnectionProtocol.respond(status: 403, body: Self.requestyBadKeyBody) + let sessionConfig = URLSessionConfiguration.ephemeral + sessionConfig.protocolClasses = [StubConnectionProtocol.self] + let provider = OpenAICompatibleProvider( + config: AIProviderConfig(preset: .requesty), + apiKey: "key", + session: URLSession(configuration: sessionConfig) + ) + let error = await thrownError { _ = try await provider.testConnection() } + #expect(isAuthenticationFailure(error)) + #expect(StubConnectionProtocol.lastRequestedURL() == "https://router.requesty.ai/v1/chat/completions") + } + + @Test("A plain custom provider built from its configuration keeps a 403 as a server error") + func plainCustomProviderDoesNot() async { + StubConnectionProtocol.respond(status: 403, body: Self.requestyBadKeyBody) + let sessionConfig = URLSessionConfiguration.ephemeral + sessionConfig.protocolClasses = [StubConnectionProtocol.self] + let provider = OpenAICompatibleProvider( + config: AIProviderConfig(type: .custom, endpoint: "https://host/v1"), + apiKey: "key", + session: URLSession(configuration: sessionConfig) + ) + let error = await thrownError { _ = try await provider.testConnection() } + #expect(error != nil) + #expect(!isAuthenticationFailure(error)) + } + @Test("A 200 is a working connection") func acceptsOK() async throws { StubConnectionProtocol.respond(status: 200, body: "{}") diff --git a/TableProTests/Core/AI/OpenAICompatibleProviderRequestTests.swift b/TableProTests/Core/AI/OpenAICompatibleProviderRequestTests.swift new file mode 100644 index 0000000000..e531b04411 --- /dev/null +++ b/TableProTests/Core/AI/OpenAICompatibleProviderRequestTests.swift @@ -0,0 +1,137 @@ +// +// OpenAICompatibleProviderRequestTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +struct OpenAICompatibleProviderRequestTests { + private func requestBody( + type: AIProviderType = .custom, + effort: ReasoningEffort? = nil, + maxOutputTokens: Int? = nil, + providerID: UUID? = nil, + catalog: AIModelCatalog = AIModelCatalog() + ) throws -> [String: Any] { + let provider = OpenAICompatibleProvider( + endpoint: type == .ollama ? "http://localhost:11434" : "https://host/v1", + apiKey: "key", + providerType: type, + model: "m", + maxOutputTokens: maxOutputTokens, + providerID: providerID, + catalog: catalog + ) + let request = try provider.buildChatCompletionRequest( + turns: [ChatTurnWire(role: .user, blocks: [.text("hi")])], + options: ChatTransportOptions(model: "m", reasoningEffort: effort) + ) + let data = try #require(request.httpBody) + let object = try JSONSerialization.jsonObject(with: data) + return try #require(object as? [String: Any]) + } + + /// The reported defect: the sheet offered Off, Low, Medium and High for every model on an + /// OpenAI-compatible provider, and the request was the same whichever was picked. + @Test("A chosen reasoning effort is sent as reasoning_effort") + func effortIsSent() throws { + #expect(try requestBody(effort: .high)["reasoning_effort"] as? String == "high") + #expect(try requestBody(type: .openRouter, effort: .low)["reasoning_effort"] as? String == "low") + } + + @Test("With reasoning off, the request carries no effort") + func noEffortWhenOff() throws { + #expect(try requestBody(effort: nil)["reasoning_effort"] == nil) + } + + @Test("The effort is withheld from a model its server lists as non-reasoning") + func effortWithheldFromANonReasoningModel() throws { + let catalog = AIModelCatalog() + let provider = UUID() + catalog.store(providerID: provider, models: [AIModelInfo(id: "m", reasoning: .unsupported)]) + let body = try requestBody(effort: .high, providerID: provider, catalog: catalog) + #expect(body["reasoning_effort"] == nil) + } + + @Test("The effort is sent to a model its server lists as reasoning, or says nothing about") + func effortSentWhenSupportedOrUnstated() throws { + let catalog = AIModelCatalog() + let reasoning = UUID() + catalog.store(providerID: reasoning, models: [ + AIModelInfo(id: "m", reasoning: AIReasoningSupport(mode: .effortOnly, effortLevels: [.low, .medium, .high])) + ]) + #expect( + try requestBody(effort: .medium, providerID: reasoning, catalog: catalog)["reasoning_effort"] as? String + == "medium" + ) + + let silent = UUID() + catalog.store(providerID: silent, models: [AIModelInfo(id: "m")]) + #expect( + try requestBody(effort: .medium, providerID: silent, catalog: catalog)["reasoning_effort"] as? String + == "medium" + ) + } + + @Test("An OpenAI-compatible server gets max_tokens and usage in the stream") + func chatCompletionsLimits() throws { + let body = try requestBody(maxOutputTokens: 512) + #expect(body["max_tokens"] as? Int == 512) + #expect(body["options"] == nil) + #expect(body["stream_options"] != nil) + } + + /// Ollama's native route ignores a top-level max_tokens, so the Max output tokens field did + /// nothing there. + @Test("Ollama gets its output limit as options.num_predict, and no OpenAI-only fields") + func ollamaLimits() throws { + let body = try requestBody(type: .ollama, effort: .high, maxOutputTokens: 512) + let options = try #require(body["options"] as? [String: Any]) + #expect(options["num_predict"] as? Int == 512) + #expect(body["max_tokens"] == nil) + #expect(body["stream_options"] == nil) + #expect(body["reasoning_effort"] == nil) + } + + private func imageTurn(_ source: ChatImageInput.Source, text: String) -> ChatTurnWire { + ChatTurnWire(role: .user, blocks: [.text(text), .image(ChatImageInput(source: source))]) + } + + /// Ollama's native route takes a string content and base64 images beside it. The OpenAI + /// content-part array is not a string, so it rejected every message that carried an image. + @Test("Ollama gets an attached image as base64 beside a string content") + func ollamaImageShape() throws { + let bytes = Data([0x89, 0x50, 0x4E, 0x47]) + let filename = AIImageCache.shared.store(data: bytes, mediaType: "image/png") + defer { AIImageCache.shared.delete(filename: filename) } + + let provider = OpenAICompatibleProvider(endpoint: "http://localhost:11434", apiKey: nil, providerType: .ollama) + let messages = provider.encodeTurn( + imageTurn(.cacheFile(filename: filename, mediaType: "image/png"), text: "what is this") + ) + #expect(messages.count == 1) + #expect(messages[0]["content"] as? String == "what is this") + #expect(messages[0]["images"] as? [String] == [bytes.base64EncodedString()]) + } + + @Test("Ollama keeps the text of a turn whose image has no bytes to send") + func ollamaRemoteImageKeepsTheText() throws { + let url = try #require(URL(string: "https://example.com/a.png")) + let provider = OpenAICompatibleProvider(endpoint: "http://localhost:11434", apiKey: nil, providerType: .ollama) + let messages = provider.encodeTurn(imageTurn(.remoteURL(url, mediaType: "image/png"), text: "what is this")) + #expect(messages.count == 1) + #expect(messages[0]["content"] as? String == "what is this") + #expect(messages[0]["images"] == nil) + } + + @Test("An OpenAI-compatible server still gets an image as an image_url content part") + func chatCompletionsImageShape() throws { + let url = try #require(URL(string: "https://example.com/a.png")) + let provider = OpenAICompatibleProvider(endpoint: "https://host/v1", apiKey: "key", providerType: .custom) + let messages = provider.encodeTurn(imageTurn(.remoteURL(url, mediaType: "image/png"), text: "what is this")) + let parts = try #require(messages.first?["content"] as? [[String: Any]]) + #expect(parts.map { $0["type"] as? String } == ["text", "image_url"]) + } +} diff --git a/TableProTests/ViewModels/AIChatViewModelImageGateTests.swift b/TableProTests/ViewModels/AIChatViewModelImageGateTests.swift new file mode 100644 index 0000000000..ea648e9bdc --- /dev/null +++ b/TableProTests/ViewModels/AIChatViewModelImageGateTests.swift @@ -0,0 +1,56 @@ +// +// AIChatViewModelImageGateTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +struct AIChatViewModelImageGateTests { + init() { + AIProviderRegistration.registerAll() + } + + /// The reported defect: the composer took an image for any model on a provider that accepts + /// images at all, and a text-only model on a router then failed the turn. + @Test("The composer refuses an image for a model its provider lists as text-only") + func textOnlyModelRefusesImages() { + let catalog = AIModelCatalog() + let router = AIProviderConfig(preset: .requesty) + catalog.store(providerID: router.id, models: [ + AIModelInfo(id: "alibaba/qwen-max", modalities: [.text]), + AIModelInfo(id: "openai/gpt-5.5", modalities: [.text, .image]) + ]) + #expect(!AIChatViewModel.acceptsImages(config: router, model: "alibaba/qwen-max", catalog: catalog)) + #expect(AIChatViewModel.acceptsImages(config: router, model: "openai/gpt-5.5", catalog: catalog)) + } + + @Test("What one provider lists does not decide for another provider's model of the same name") + func listsDoNotLeakBetweenProviders() { + let catalog = AIModelCatalog() + let router = AIProviderConfig(preset: .requesty) + let local = AIProviderConfig(type: .custom, endpoint: "http://localhost:1234/v1") + catalog.store(providerID: router.id, models: [AIModelInfo(id: "qwen", modalities: [.text])]) + #expect(!AIChatViewModel.acceptsImages(config: router, model: "qwen", catalog: catalog)) + #expect(AIChatViewModel.acceptsImages(config: local, model: "qwen", catalog: catalog)) + } + + @Test("A model the provider's list says nothing about still takes images") + func unlistedModelTakesImages() { + let catalog = AIModelCatalog() + let local = AIProviderConfig(type: .llamaCpp) + #expect(AIChatViewModel.acceptsImages(config: local, model: "llava", catalog: catalog)) + + catalog.store(providerID: local.id, models: [AIModelInfo(id: "llava")]) + #expect(AIChatViewModel.acceptsImages(config: local, model: "llava", catalog: catalog)) + } + + @Test("A provider that takes no images refuses them whatever its model list says") + func imagelessProviderRefuses() { + let catalog = AIModelCatalog() + let agent = AIProviderConfig(type: .claudeAgent) + catalog.store(providerID: agent.id, models: [AIModelInfo(id: "opus", modalities: [.text, .image])]) + #expect(!AIChatViewModel.acceptsImages(config: agent, model: "opus", catalog: catalog)) + } +} diff --git a/TableProUITests/AIProviderPresetUITests.swift b/TableProUITests/AIProviderPresetUITests.swift new file mode 100644 index 0000000000..f8fe5b0de0 --- /dev/null +++ b/TableProUITests/AIProviderPresetUITests.swift @@ -0,0 +1,49 @@ +import XCTest + +/// A preset earns its place in the menu by saving a provider that works: the vendor's Base URL +/// filled in, and Save held back until the key the vendor requires is there. Nothing is typed into +/// the key field, so the sheet never reaches the network. +final class AIProviderPresetUITests: UITestCase { + func testRequestyPresetFillsItsBaseURLAndWaitsForAKey() throws { + let app = try launchApp() + XCTAssertTrue(app.windows.firstMatch.waitToExist(timeout: 10)) + + let settingsMenuItem = app.menuBars.menuItems["Settings…"] + XCTAssertTrue(settingsMenuItem.waitToExist(timeout: 10)) + settingsMenuItem.click() + + let settingsWindow = app.windows["settings"] + XCTAssertTrue(settingsWindow.waitToExist(timeout: 10)) + + let aiPaneButton = app.toolbars.buttons["AI"] + XCTAssertTrue(aiPaneButton.waitToExist(timeout: 10)) + aiPaneButton.click() + + /// A borderless SwiftUI menu is not reliably a menu button in the accessibility tree, so it + /// is found by its title whatever element type carries it. + let addProvider = settingsWindow.descendants(matching: .any) + .matching(NSPredicate(format: "title == %@ OR label == %@", "Add Provider…", "Add Provider…")) + .firstMatch + XCTAssertTrue(addProvider.waitToExist(timeout: 10)) + addProvider.click() + + let requesty = app.menuItems["Requesty"] + XCTAssertTrue(requesty.waitToExist(timeout: 10)) + requesty.click() + + let sheet = settingsWindow.sheets.firstMatch + XCTAssertTrue(sheet.waitToExist(timeout: 10)) + + let baseURL = sheet.textFields["Base URL"] + XCTAssertTrue(baseURL.waitToExist(timeout: 10)) + XCTAssertEqual(baseURL.value as? String, "https://router.requesty.ai") + + let name = sheet.textFields["Name"] + XCTAssertTrue(name.waitToExist(timeout: 10)) + XCTAssertEqual(name.value as? String, "Requesty") + + let save = sheet.buttons["Save"] + XCTAssertTrue(save.waitToExist(timeout: 10)) + XCTAssertFalse(save.isEnabled, "Requesty requires a key, so Save has to wait for one") + } +} diff --git a/docs/features/ai-assistant.mdx b/docs/features/ai-assistant.mdx index 16d5ddc126..631bf0c85c 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, and review, explain, optimize, and fix-error across 14 providers" +description: "Built-in AI for SQL: chat with tool calling, inline suggestions, and review, explain, optimize, and fix-error across 15 providers" --- One provider is active at a time and answers every AI request in the app, from the chat panel to the ghost text in the editor. Switch it and all of them switch with it. Keys are yours and live in the macOS Keychain, so every request bills to your own account with that provider. @@ -25,11 +25,11 @@ Open **Settings > AI** (`Cmd+,`). **Enable AI Features** at the top gates the wh Paste an API key, or sign in for GitHub Copilot, ChatGPT, Cursor, and xAI. Leave the key empty for a Custom provider whose server checks none, such as a self-hosted vLLM or LM Studio with authentication off. - Type a model name or pick one from the fetched list, then click **Test Connection**. + Type a model name or pick one from the fetched list, then click **Test Connection**. A provider that lists several models with no default starts on **Other…** with an empty **Model ID**, so pick one before saving. -The **Active Provider** picker decides which one handles requests. Setting it to **None** leaves any open conversation readable but read-only, with a **Settings…** button under it. Where a model supports reasoning effort, its detail sheet adds a picker for it and replies render their thinking in a collapsible **Reasoning** block. +The **Active Provider** picker decides which one handles requests. Setting it to **None** leaves any open conversation readable but read-only, with a **Settings…** button under it. Where a model supports reasoning effort, its detail sheet adds a picker for it and replies render their thinking in a collapsible **Reasoning** block. Gemini and Ollama have no picker, and on a router the picker follows the model: it is gone for a model the router lists as non-reasoning. With no preference, start with Claude or OpenAI on an API key: both take a key, tools, and images with no further setup. The others each come with something to know. @@ -39,6 +39,7 @@ With no preference, start with Claude or OpenAI on an API key: both take a key, | ChatGPT | Your ChatGPT account, on the Codex quota from Plus, Pro, Business, and Enterprise plans. **Import from Codex CLI** reuses an existing login | Unofficial interface, may change | | Cursor | API key, or **Sign in with Cursor** with the Cursor CLI installed | Runs as an agent, not a chat-completions endpoint, so it cannot call TablePro's tools: Edit and Agent modes run no queries through it | | xAI | API key, or **Sign in with xAI** on a SuperGrok or X Premium+ subscription | Sign-in opens a Grok Build consent screen and is unofficial. Presets: Grok 4.5, Grok 4.3 | +| Requesty | API key | An OpenAI-compatible router. The Base URL is filled in; set it to `https://router.eu.requesty.ai` for EU routing | | Ollama, llama.cpp, MLX | None. Start the server first | Presets fill in `http://localhost:11434` for Ollama and `http://localhost:8080` for `llama-server` and `mlx_lm.server`. Start `llama-server` with `--jinja` for tool calling | | Claude Agent | Claude Code's `claude` command, no key | Bills against a Claude subscription, with tradeoffs | @@ -65,7 +66,7 @@ Type a question and press Return. Code blocks carry **Copy** and **Insert**, and Conversations save themselves and take their title from your first message. The ellipsis menu at the trailing end of the pane's header holds **New Conversation**, **Conversation History** with the current conversation ticked, and **Clear Recents**, which asks before it deletes every one of them. **File > Session** carries the same three, so they work with the pane closed and in [Agent mode](/features/agent-mode). Editing a message you already sent puts its text and attachments back in the composer and drops that turn and everything after it. -Paste or drag images into the composer on any provider that takes them, which is all of them except GitHub Copilot, Cursor, ChatGPT, and Claude Agent. A drop that carries files the composer cannot read attaches the rest and says how many it kept. +Paste or drag images into the composer on any provider that takes them, which is all of them except GitHub Copilot, Cursor, ChatGPT, and Claude Agent. On OpenRouter and Requesty the model decides: one the router lists as text-only takes no images. A drop that carries files the composer cannot read attaches the rest and says how many it kept. The focused composer carries a colored highlight. Right-click it and turn **Highlight When Focused** off for the standard macOS focus ring instead. The choice is app-wide and survives restarts. Reduce Transparency and Increase Contrast, both under **System Settings > Accessibility > Display**, also replace the highlight with the focus ring.