diff --git a/CHANGELOG.md b/CHANGELOG.md index 9078458221..d5799fec9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Interface, Sidebar and Toolbar color groups from the theme editor and the theme file. - Fonts from the theme file. +- Cursor AI provider. A saved Cursor provider is dropped; Cursor still reaches TablePro through the MCP server. +- Sign in with xAI. An xAI provider now needs an xAI API key. ### Fixed diff --git a/TablePro/Core/AI/Cursor/CursorAI.swift b/TablePro/Core/AI/Cursor/CursorAI.swift deleted file mode 100644 index 95cc32fd6f..0000000000 --- a/TablePro/Core/AI/Cursor/CursorAI.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// CursorAI.swift -// TablePro -// - -import Foundation - -enum CursorAI { - static let baseURL = "https://api.cursor.com" - - static let curatedModels: [(id: String, name: String)] = [ - ("composer-2.5", "Composer 2.5"), - ("auto", "Auto"), - ("composer-2", "Composer 2"), - ("claude-4.5-sonnet", "Claude 4.5 Sonnet"), - ("claude-opus-4.8", "Claude Opus 4.8"), - ("gpt-5.5", "GPT-5.5"), - ("gpt-5.4", "GPT-5.4"), - ("gemini-3-pro", "Gemini 3 Pro") - ] - - static var curatedModelIDs: [String] { - curatedModels.map { $0.id } - } -} diff --git a/TablePro/Core/AI/Cursor/CursorAgentCLI.swift b/TablePro/Core/AI/Cursor/CursorAgentCLI.swift deleted file mode 100644 index 881c65f6b3..0000000000 --- a/TablePro/Core/AI/Cursor/CursorAgentCLI.swift +++ /dev/null @@ -1,84 +0,0 @@ -// -// CursorAgentCLI.swift -// TablePro -// - -import Foundation - -enum CursorAgentError: Error, LocalizedError { - case notInstalled - case launchFailed(String) - - var errorDescription: String? { - switch self { - case .notInstalled: - return String(localized: "The Cursor CLI is not installed. Install it, then sign in.") - case .launchFailed(let detail): - return String(format: String(localized: "Cursor CLI failed: %@"), detail) - } - } -} - -struct CursorAgentCLI: Sendable { - static let installCommand = "curl https://cursor.com/install -fsS | bash" - - private static let process = AgentCLIProcess(label: "agent") - - static var discovery: AgentCLIDiscovery { - let home = FileManager.default.homeDirectoryForCurrentUser.path - return AgentCLIDiscovery( - candidatePaths: [ - "\(home)/.local/bin/agent", - "/usr/local/bin/agent", - "/opt/homebrew/bin/agent" - ], - preferredPathDirectories: [ - "\(home)/.local/bin", "/opt/homebrew/bin", "/usr/local/bin", - "/usr/bin", "/bin", "/usr/sbin", "/sbin" - ] - ) - } - - static func executableURL() -> URL? { - discovery.resolveExecutable(override: nil) - } - - var isInstalled: Bool { Self.executableURL() != nil } - - func run(_ arguments: [String]) async throws -> (code: Int32, output: String) { - do { - return try await Self.process.run(try Self.makeLaunch(arguments)) - } catch { - throw Self.translate(error) - } - } - - func stream(_ arguments: [String]) -> AsyncThrowingStream { - do { - return Self.process.streamLines(try Self.makeLaunch(arguments)) - } catch { - let translated = Self.translate(error) - return AsyncThrowingStream { $0.finish(throwing: translated) } - } - } - - private static func makeLaunch(_ arguments: [String]) throws -> AgentCLILaunch { - guard let executable = executableURL() else { throw CursorAgentError.notInstalled } - return AgentCLILaunch( - executable: executable, - arguments: arguments, - environment: discovery.makeEnvironment() - ) - } - - private static func translate(_ error: Error) -> Error { - switch error { - case AgentCLIError.notInstalled: - return CursorAgentError.notInstalled - case AgentCLIError.launchFailed(let detail): - return CursorAgentError.launchFailed(detail) - default: - return error - } - } -} diff --git a/TablePro/Core/AI/Cursor/CursorAgentProvider.swift b/TablePro/Core/AI/Cursor/CursorAgentProvider.swift deleted file mode 100644 index a3dfef30f0..0000000000 --- a/TablePro/Core/AI/Cursor/CursorAgentProvider.swift +++ /dev/null @@ -1,105 +0,0 @@ -// -// CursorAgentProvider.swift -// TablePro -// - -import Foundation -import os - -final class CursorAgentProvider: ChatTransport { - private static let logger = Logger(subsystem: "com.TablePro", category: "CursorAgentProvider") - - private let model: String - private let cli: CursorAgentCLI - - init(model: String, cli: CursorAgentCLI = CursorAgentCLI()) { - self.model = model.trimmingCharacters(in: .whitespacesAndNewlines) - self.cli = cli - } - - func streamChat( - turns: [ChatTurnWire], - options: ChatTransportOptions - ) -> AsyncThrowingStream { - AsyncThrowingStream { continuation in - let workspace = Self.makeWorkspace() - let prompt = CursorProvider.renderPrompt(turns: turns, options: options) - let lines = cli.stream(Self.inferenceArguments(prompt: prompt, model: model, workspace: workspace?.path)) - - let task = Task { - do { - for try await line in lines { - if Task.isCancelled { break } - guard let json = Self.decodeJSON(line), - let text = Self.incrementalText(json) else { continue } - continuation.yield(.textDelta(text)) - } - continuation.finish() - } catch { - continuation.finish(throwing: error) - } - Self.removeWorkspace(workspace) - } - - continuation.onTermination = { _ in - task.cancel() - } - } - } - - func fetchAvailableModels() async throws -> [AIModelInfo] { - CursorAI.curatedModelIDs.map { AIModelInfo(id: $0, reasoning: .unsupported) } - } - - func testConnection() async throws -> Bool { - let result = try await cli.run(["status"]) - if result.code == 0 { return true } - throw AIProviderError.authenticationFailed(String(localized: "Not signed in to Cursor. Sign in first.")) - } - - static func inferenceArguments(prompt: String, model: String, workspace: String?) -> [String] { - var arguments = ["-p", "--output-format", "stream-json", "--stream-partial-output", "--trust"] - if let workspace { - arguments += ["--workspace", workspace] - } - if !model.isEmpty { - arguments += ["--model", model] - } - arguments += ["--", prompt] - return arguments - } - - static func incrementalText(_ json: [String: Any]) -> String? { - guard json["type"] as? String == "assistant", - json["timestamp_ms"] != nil, - let text = assistantText(json), !text.isEmpty else { - return nil - } - return text - } - - static func assistantText(_ json: [String: Any]) -> String? { - guard let message = json["message"] as? [String: Any], - let content = message["content"] as? [[String: Any]], - let text = content.first?["text"] as? String else { - return nil - } - return text - } - - private static func decodeJSON(_ line: String) -> [String: Any]? { - guard let data = line.data(using: .utf8) else { return nil } - return try? JSONSerialization.jsonObject(with: data) as? [String: Any] - } - - private static func makeWorkspace() -> URL? { - let url = FileManager.default.temporaryDirectory.appending(path: "cursor-agent-\(UUID().uuidString)") - try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) - return FileManager.default.fileExists(atPath: url.path) ? url : nil - } - - private static func removeWorkspace(_ url: URL?) { - guard let url else { return } - try? FileManager.default.removeItem(at: url) - } -} diff --git a/TablePro/Core/AI/Cursor/CursorAgentService.swift b/TablePro/Core/AI/Cursor/CursorAgentService.swift deleted file mode 100644 index 83ece9c266..0000000000 --- a/TablePro/Core/AI/Cursor/CursorAgentService.swift +++ /dev/null @@ -1,98 +0,0 @@ -// -// CursorAgentService.swift -// TablePro -// - -import Combine -import Foundation -import os - -@MainActor -final class CursorAgentService: ObservableObject { - static let shared = CursorAgentService() - - nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "CursorAgentService") - - enum AuthState: Sendable, Equatable { - case notInstalled - case signedOut - case signingIn - case signedIn(account: String) - - var isSignedIn: Bool { - if case .signedIn = self { return true } - return false - } - } - - @Published private(set) var authState: AuthState = .signedOut - @Published private(set) var errorMessage: String? - - private let cli: CursorAgentCLI - private var signInTask: Task? - - init(cli: CursorAgentCLI = CursorAgentCLI()) { - self.cli = cli - } - - func refreshStatus() async { - guard cli.isInstalled else { - authState = .notInstalled - return - } - do { - let result = try await cli.run(["status"]) - authState = result.code == 0 - ? .signedIn(account: Self.parseAccount(result.output)) - : .signedOut - } catch { - authState = cli.isInstalled ? .signedOut : .notInstalled - } - } - - func signIn() { - guard cli.isInstalled else { - authState = .notInstalled - return - } - guard signInTask == nil else { return } - errorMessage = nil - authState = .signingIn - signInTask = Task { - do { - let result = try await cli.run(["login"]) - if !Task.isCancelled, result.code != 0 { - errorMessage = result.output.isEmpty - ? String(localized: "Cursor sign-in failed.") - : result.output - } - } catch { - Self.logger.error("Cursor CLI sign-in failed: \(error.publicLogShape, privacy: .public)") - if !Task.isCancelled { - errorMessage = error.localizedDescription - } - } - signInTask = nil - await refreshStatus() - } - } - - func cancelSignIn() { - signInTask?.cancel() - signInTask = nil - } - - func signOut() async { - _ = try? await cli.run(["logout"]) - errorMessage = nil - await refreshStatus() - } - - private static func parseAccount(_ output: String) -> String { - let tokens = output.split(whereSeparator: { $0 == " " || $0 == "\n" || $0 == "\t" || $0 == "\r" }) - for token in tokens where token.contains("@") && token.contains(".") { - return String(token.trimmingCharacters(in: CharacterSet(charactersIn: "<>(),"))) - } - return "" - } -} diff --git a/TablePro/Core/AI/Cursor/CursorProvider.swift b/TablePro/Core/AI/Cursor/CursorProvider.swift deleted file mode 100644 index 1c73b00cc9..0000000000 --- a/TablePro/Core/AI/Cursor/CursorProvider.swift +++ /dev/null @@ -1,213 +0,0 @@ -// -// CursorProvider.swift -// TablePro -// - -import Foundation -import os - -final class CursorProvider: ChatTransport { - private static let logger = Logger(subsystem: "com.TablePro", category: "CursorProvider") - - private let apiKey: String - private let model: String - private let session: URLSession - - init(apiKey: String, model: String, session: URLSession = URLSession(configuration: .ephemeral)) { - self.apiKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) - self.model = model.trimmingCharacters(in: .whitespacesAndNewlines) - self.session = session - } - - func streamChat( - turns: [ChatTurnWire], - options: ChatTransportOptions - ) -> AsyncThrowingStream { - AsyncThrowingStream { continuation in - let task = Task { - do { - let run = try await launchAgent(turns: turns, options: options) - let request = try streamRequest(run: run) - let (bytes, response) = try await session.bytes(for: request) - guard let httpResponse = response as? HTTPURLResponse else { - throw AIProviderError.networkError("Invalid response") - } - guard httpResponse.statusCode == 200 else { - let body = try await AIProvider.collectErrorBody(from: bytes) - throw AIProviderError.mapHTTPError(statusCode: httpResponse.statusCode, body: body) - } - - var parser = StreamParser() - events: for try await line in bytes.lines { - if Task.isCancelled { break } - switch parser.consume(line) { - case .text(let text): - continuation.yield(.textDelta(text)) - case .done: - break events - case nil: - continue - } - } - continuation.finish() - } catch { - continuation.finish(throwing: error) - } - } - - continuation.onTermination = { _ in - task.cancel() - } - } - } - - func fetchAvailableModels() async throws -> [AIModelInfo] { - try await fetchModelIDs().map { AIModelInfo(id: $0, reasoning: .unsupported) } - } - - private func fetchModelIDs() async throws -> [String] { - var request = URLRequest(url: try Self.url("/v1/models")) - request.timeoutInterval = AIProvider.modelListTimeout - request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") - let data: Data - let response: URLResponse - do { - (data, response) = try await session.data(for: request) - } catch { - Self.logger.warning("Cursor model fetch failed: \(error.publicLogShape, privacy: .public)") - throw AIProviderError.networkError("Failed to fetch models") - } - guard let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200, - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let items = json["items"] as? [[String: Any]] - else { - throw AIProviderError.networkError("Failed to fetch models") - } - let fetched = items.compactMap { $0["id"] as? String } - let curated = Set(CursorAI.curatedModelIDs) - return CursorAI.curatedModelIDs + fetched.filter { !curated.contains($0) }.sorted() - } - - func testConnection() async throws -> Bool { - var request = URLRequest(url: try Self.url("/v1/models")) - request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") - let (data, response) = try await session.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { return false } - if httpResponse.statusCode == 200 { - return true - } - if httpResponse.statusCode == 401 { - throw AIProviderError.authenticationFailed("") - } - let body = String(data: data, encoding: .utf8) ?? "" - throw AIProviderError.mapHTTPError(statusCode: httpResponse.statusCode, body: body) - } - - private func launchAgent( - turns: [ChatTurnWire], - options: ChatTransportOptions - ) async throws -> (agentID: String, runID: String) { - var request = URLRequest(url: try Self.url("/v1/agents")) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") - let body = Self.launchBody(prompt: Self.renderPrompt(turns: turns, options: options), model: model) - request.httpBody = try JSONSerialization.data(withJSONObject: body) - - let (data, response) = try await session.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { - throw AIProviderError.networkError("Invalid response") - } - guard (200..<300).contains(httpResponse.statusCode) else { - throw AIProviderError.mapHTTPError( - statusCode: httpResponse.statusCode, - body: String(data: data, encoding: .utf8) ?? "" - ) - } - guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let agent = json["agent"] as? [String: Any], - let agentID = agent["id"] as? String, - let run = json["run"] as? [String: Any], - let runID = run["id"] as? String - else { - throw AIProviderError.networkError("Cursor did not return an agent run") - } - return (agentID, runID) - } - - private func streamRequest(run: (agentID: String, runID: String)) throws -> URLRequest { - var request = URLRequest(url: try Self.url("/v1/agents/\(run.agentID)/runs/\(run.runID)/stream")) - request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") - request.setValue("text/event-stream", forHTTPHeaderField: "Accept") - return request - } - - static func renderPrompt(turns: [ChatTurnWire], options: ChatTransportOptions) -> String { - AgentPromptRenderer.renderPrompt(turns: turns, includingSystemPrompt: options.systemPrompt) - } - - static func launchBody(prompt: String, model: String) -> [String: Any] { - var body: [String: Any] = ["prompt": ["text": prompt]] - if !model.isEmpty { - body["model"] = ["id": model] - } - return body - } - - private static func url(_ path: String) throws -> URL { - guard let url = URL(string: CursorAI.baseURL + path) else { - throw AIProviderError.invalidEndpoint(CursorAI.baseURL) - } - return url - } - - private static func sseField(_ line: String, _ name: String) -> String? { - let prefix = "\(name):" - guard line.hasPrefix(prefix) else { return nil } - return String(line.dropFirst(prefix.count)).trimmingCharacters(in: .whitespaces) - } - - private static func decodeJSON(_ payload: String) -> [String: Any]? { - guard let data = payload.data(using: .utf8) else { return nil } - return try? JSONSerialization.jsonObject(with: data) as? [String: Any] - } - - struct StreamParser { - enum Output: Equatable { - case text(String) - case done - } - - private var event = "" - private var emittedText = false - - mutating func consume(_ line: String) -> Output? { - if line.isEmpty { - event = "" - return nil - } - if let name = CursorProvider.sseField(line, "event") { - event = name - return nil - } - guard let payload = CursorProvider.sseField(line, "data"), - let json = CursorProvider.decodeJSON(payload) else { - return nil - } - switch event { - case "assistant": - guard let text = json["text"] as? String, !text.isEmpty else { return nil } - emittedText = true - return .text(text) - case "result": - guard !emittedText, let text = json["text"] as? String, !text.isEmpty else { return nil } - return .text(text) - case "done": - return .done - default: - return nil - } - } - } -} diff --git a/TablePro/Core/AI/OAuthProviderService.swift b/TablePro/Core/AI/OAuthProviderService.swift index 1886b5eb3f..85e26e3674 100644 --- a/TablePro/Core/AI/OAuthProviderService.swift +++ b/TablePro/Core/AI/OAuthProviderService.swift @@ -34,8 +34,6 @@ enum OAuthProviderRegistry { return CopilotService.shared case .chatgptCodex: return ChatGPTCodexService.shared - case .xai: - return XAIService.shared default: return nil } @@ -67,16 +65,3 @@ extension ChatGPTCodexService: OAuthProviderService { } } } - -extension XAIService: OAuthProviderService { - var oauthState: OAuthAuthState { - switch authState { - case .signedOut: - return .signedOut - case .signingIn: - return .signingIn - case .signedIn(let email): - return .signedIn(identity: email) - } - } -} diff --git a/TablePro/Core/AI/Registry/AIProviderRegistration.swift b/TablePro/Core/AI/Registry/AIProviderRegistration.swift index 66e99615bc..f22780de9c 100644 --- a/TablePro/Core/AI/Registry/AIProviderRegistration.swift +++ b/TablePro/Core/AI/Registry/AIProviderRegistration.swift @@ -68,18 +68,15 @@ enum AIProviderRegistration { registry.register(AIProviderDescriptor( typeID: AIProviderType.xai.rawValue, capabilities: [.reasoning, .images, .endpointConfigurable, .maxOutputTokens, .modelListFetchable], - curatedModels: XAI.apiCuratedModels, + curatedModels: xaiCuratedModels, makeProvider: { config, apiKey in - if let apiKey, !apiKey.isEmpty { - return OpenAIResponsesProvider( - endpoint: config.endpoint, - apiKey: apiKey, - model: config.model, - maxOutputTokens: config.maxOutputTokens, - dialect: .xai - ) - } - return XAIGrokProvider(model: config.model) + OpenAIResponsesProvider( + endpoint: config.endpoint, + apiKey: apiKey, + model: config.model, + maxOutputTokens: config.maxOutputTokens, + dialect: .xai + ) } )) @@ -111,18 +108,6 @@ enum AIProviderRegistration { ChatGPTCodexProvider(model: config.model) } )) - - registry.register(AIProviderDescriptor( - typeID: AIProviderType.cursor.rawValue, - capabilities: [.modelListFetchable], - curatedModels: cursorCuratedModels, - makeProvider: { config, apiKey in - if let apiKey, !apiKey.isEmpty { - return CursorProvider(apiKey: apiKey, model: config.model) - } - return CursorAgentProvider(model: config.model) - } - )) } /// Reasoning and images are an envelope here, narrowed per model by what the server's own @@ -141,10 +126,6 @@ enum AIProviderRegistration { return capabilities } - private static let cursorCuratedModels: [CuratedModel] = CursorAI.curatedModels.map { - CuratedModel(id: $0.id, displayName: $0.name) - } - private static func curatedModel( id: String, displayName: String, @@ -171,6 +152,21 @@ enum AIProviderRegistration { curatedModel(id: "gpt-5.5", displayName: "GPT-5.5", provider: .openAI) ] + private static let xaiCuratedModels: [CuratedModel] = [ + CuratedModel( + id: "grok-4.5", + displayName: "Grok 4.5", + supportedEffortLevels: [.low, .medium, .high], + defaultEffort: .medium + ), + CuratedModel( + id: "grok-4.3", + displayName: "Grok 4.3", + supportedEffortLevels: [.low, .medium, .high], + defaultEffort: .low + ) + ] + private static let claudeCuratedModels: [CuratedModel] = [ curatedModel(id: "claude-opus-5", displayName: "Claude Opus 5", provider: .claude), curatedModel(id: "claude-sonnet-5", displayName: "Claude Sonnet 5", provider: .claude), diff --git a/TablePro/Core/AI/XAI/XAI.swift b/TablePro/Core/AI/XAI/XAI.swift deleted file mode 100644 index c534ae79e5..0000000000 --- a/TablePro/Core/AI/XAI/XAI.swift +++ /dev/null @@ -1,82 +0,0 @@ -// -// XAI.swift -// TablePro -// - -import Foundation - -enum XAI { - static let issuer = "https://auth.x.ai" - static let authorizeEndpoint = "\(issuer)/oauth2/authorize" - static let tokenEndpoint = "\(issuer)/oauth2/token" - static let revokeEndpoint = "\(issuer)/oauth2/revoke" - static let clientID = "b1a00492-073a-47ea-816f-4c329264a828" - static let scope = "openid profile email offline_access grok-cli:access api:access" - - static let redirectHost = "127.0.0.1" - static let preferredRedirectPort: UInt16 = 56_121 - static let redirectPath = "/callback" - - static let apiBaseURL = "https://api.x.ai" - - static let cliProxyBaseURL = "https://cli-chat-proxy.grok.com/v1" - static let cliClientVersion = "0.2.93" - static let cliTokenAuthHeader = "X-XAI-Token-Auth" - static let cliTokenAuthValue = "xai-grok-cli" - static let cliClientVersionHeader = "x-grok-client-version" - static let cliModelOverrideHeader = "x-grok-model-override" - static let userAgent = "xai-grok-workspace/\(cliClientVersion)" - - static let apiCuratedModels: [CuratedModel] = [ - CuratedModel( - id: "grok-4.5", - displayName: "Grok 4.5", - supportedEffortLevels: [.low, .medium, .high], - defaultEffort: .medium - ), - CuratedModel( - id: "grok-4.3", - displayName: "Grok 4.3", - supportedEffortLevels: [.low, .medium, .high], - defaultEffort: .low - ) - ] - - static let subscriptionModelIDs = ["grok-4.5", "grok-build", "grok-composer-2.5-fast"] - - static func redirectURI(port: UInt16) -> String { - "http://\(redirectHost):\(port)\(redirectPath)" - } -} - -enum XAIBase64URL { - static func encode(_ data: Data) -> String { - data.base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } - - static func decode(_ string: String) -> Data? { - var base64 = string - .replacingOccurrences(of: "-", with: "+") - .replacingOccurrences(of: "_", with: "/") - let remainder = base64.count % 4 - if remainder > 0 { - base64.append(String(repeating: "=", count: 4 - remainder)) - } - return Data(base64Encoded: base64) - } -} - -enum XAIIdentity { - static func email(fromIDToken idToken: String) -> String { - let segments = idToken.split(separator: ".") - guard segments.count >= 2, - let data = XAIBase64URL.decode(String(segments[1])), - let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - return "" - } - return payload["email"] as? String ?? "" - } -} diff --git a/TablePro/Core/AI/XAI/XAICallbackServer.swift b/TablePro/Core/AI/XAI/XAICallbackServer.swift deleted file mode 100644 index 43cb9c9259..0000000000 --- a/TablePro/Core/AI/XAI/XAICallbackServer.swift +++ /dev/null @@ -1,230 +0,0 @@ -// -// XAICallbackServer.swift -// TablePro -// - -import Foundation -import Network -import os - -final class XAICallbackServer: @unchecked Sendable { - enum ServerError: Error, LocalizedError { - case unavailable - case timedOut - case stateMismatch - - var errorDescription: String? { - switch self { - case .unavailable: - return String(localized: "Could not start the sign-in listener. Please try again.") - case .timedOut: - return String(localized: "Sign-in timed out. Please try again.") - case .stateMismatch: - return String(localized: "Sign-in could not be verified. Please try again.") - } - } - } - - private static let logger = Logger(subsystem: "com.TablePro", category: "XAICallbackServer") - private static let timeout: TimeInterval = 300 - private static let startTimeout: TimeInterval = 10 - - private let expectedState: String - private let lock = NSLock() - private var listener: NWListener? - private var connection: NWConnection? - private var readyContinuation: CheckedContinuation? - private var codeContinuation: CheckedContinuation? - private var readyTimeoutTask: Task? - private var timeoutTask: Task? - private var boundPort: UInt16 = XAI.preferredRedirectPort - - init(expectedState: String) { - self.expectedState = expectedState - } - - var redirectURI: String { - XAI.redirectURI(port: lock.withLock { boundPort }) - } - - func start() async throws { - do { - try await startListener(on: XAI.preferredRedirectPort) - } catch { - Self.logger.notice("xAI callback preferred port unavailable; falling back to an ephemeral port") - try await startListener(on: 0) - } - } - - func waitForCode() async throws -> String { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - lock.withLock { codeContinuation = continuation } - let task = Task { [weak self] in - try? await Task.sleep(nanoseconds: UInt64(Self.timeout * 1_000_000_000)) - self?.finishCode(.failure(ServerError.timedOut)) - } - lock.withLock { timeoutTask = task } - } - } - - func stop() { - let (task, readyTask, conn, lst): ( - Task?, Task?, NWConnection?, NWListener? - ) = lock.withLock { - let values = (timeoutTask, readyTimeoutTask, connection, listener) - timeoutTask = nil - readyTimeoutTask = nil - connection = nil - listener = nil - return values - } - task?.cancel() - readyTask?.cancel() - conn?.cancel() - lst?.cancel() - } - - private func startListener(on port: UInt16) async throws { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - let timeout = Task { [weak self] in - try? await Task.sleep(nanoseconds: UInt64(Self.startTimeout * 1_000_000_000)) - self?.finishReady(.failure(ServerError.timedOut)) - } - lock.withLock { - readyContinuation = continuation - readyTimeoutTask = timeout - } - do { - try makeListener(on: port) - } catch { - finishReady(.failure(ServerError.unavailable)) - } - } - } - - private func makeListener(on port: UInt16) throws { - let endpointPort = NWEndpoint.Port(rawValue: port) ?? .any - let parameters = NWParameters.tcp - parameters.requiredLocalEndpoint = NWEndpoint.hostPort(host: .ipv4(.loopback), port: endpointPort) - - let listener = try NWListener(using: parameters) - let previous: NWListener? = lock.withLock { - let old = self.listener - self.listener = listener - return old - } - previous?.cancel() - - listener.stateUpdateHandler = { [weak self] state in - switch state { - case .ready: - if let resolvedPort = listener.port?.rawValue { - self?.lock.withLock { self?.boundPort = resolvedPort } - } - self?.finishReady(.success(())) - case .failed(let error): - Self.logger.error("xAI callback listener failed: \(error.publicLogShape, privacy: .public)") - self?.finishReady(.failure(ServerError.unavailable)) - self?.finishCode(.failure(ServerError.unavailable)) - default: - break - } - } - listener.newConnectionHandler = { [weak self] connection in - self?.handleConnection(connection) - } - listener.start(queue: .global(qos: .userInitiated)) - } - - private func handleConnection(_ newConnection: NWConnection) { - lock.withLock { connection = newConnection } - newConnection.stateUpdateHandler = { [weak self] state in - if case .ready = state { - self?.readRequest(from: newConnection) - } - } - newConnection.start(queue: .global(qos: .userInitiated)) - } - - private func readRequest(from connection: NWConnection) { - connection.receive(minimumIncompleteLength: 1, maximumLength: 16_384) { [weak self] content, _, isComplete, error in - guard let self else { return } - if error != nil { - connection.cancel() - return - } - guard let data = content, let request = String(data: data, encoding: .utf8), - let query = Self.parseCallback(request) else { - if isComplete { - connection.cancel() - } else { - self.readRequest(from: connection) - } - return - } - let matches = query.state == self.expectedState - Self.send(html: matches ? Self.successPage : Self.failurePage, to: connection) - self.finishCode(matches ? .success(query.code) : .failure(ServerError.stateMismatch)) - } - } - - private func finishReady(_ result: Result) { - let (continuation, timeout): (CheckedContinuation?, Task?) = lock.withLock { - let pendingContinuation = readyContinuation - let pendingTimeout = readyTimeoutTask - readyContinuation = nil - readyTimeoutTask = nil - return (pendingContinuation, pendingTimeout) - } - timeout?.cancel() - continuation?.resume(with: result) - } - - private func finishCode(_ result: Result) { - let continuation: CheckedContinuation? = lock.withLock { - let pending = codeContinuation - codeContinuation = nil - return pending - } - guard let continuation else { return } - continuation.resume(with: result) - stop() - } - - static func parseCallback(_ request: String) -> (code: String, state: String)? { - guard let requestLine = request.components(separatedBy: "\r\n").first, - let target = requestLine.components(separatedBy: " ").dropFirst().first, - let components = URLComponents(string: "http://127.0.0.1\(target)"), - let items = components.queryItems, - let code = items.first(where: { $0.name == "code" })?.value, - let state = items.first(where: { $0.name == "state" })?.value else { - return nil - } - return (code, state) - } - - private static func send(html: String, to connection: NWConnection) { - let body = Array(html.utf8) - let headers = "HTTP/1.1 200 OK\r\n" - + "Content-Type: text/html; charset=utf-8\r\n" - + "Content-Length: \(body.count)\r\n" - + "Connection: close\r\n\r\n" - var response = Data(headers.utf8) - response.append(contentsOf: body) - connection.send(content: response, completion: .contentProcessed { _ in - connection.cancel() - }) - } - - private static let successPage = """ - TablePro - -

Signed in to xAI

You can return to TablePro.

- """ - - private static let failurePage = """ - TablePro - -

Sign-in failed

Please return to TablePro and try again.

- """ -} diff --git a/TablePro/Core/AI/XAI/XAIGrokProvider.swift b/TablePro/Core/AI/XAI/XAIGrokProvider.swift deleted file mode 100644 index 03bdb570a4..0000000000 --- a/TablePro/Core/AI/XAI/XAIGrokProvider.swift +++ /dev/null @@ -1,120 +0,0 @@ -// -// XAIGrokProvider.swift -// TablePro -// - -import Foundation - -final class XAIGrokProvider: ChatTransport { - static let defaultInstructions = "You are a coding assistant helping with SQL and database tasks." - - private let model: String - private let tokenStore: XAITokenStore - private let session: URLSession - - init( - model: String, - tokenStore: XAITokenStore = .shared, - session: URLSession = URLSession(configuration: .ephemeral) - ) { - self.model = model.trimmingCharacters(in: .whitespacesAndNewlines) - self.tokenStore = tokenStore - self.session = session - } - - func streamChat( - turns: [ChatTurnWire], - options: ChatTransportOptions - ) -> AsyncThrowingStream { - ResponsesEventStream.make( - session: session, - buildRequest: { [self] in try await buildRequest(turns: turns, options: options, stream: true) }, - refreshOnUnauthorized: { [tokenStore] in _ = try await tokenStore.forceRefresh() } - ) - } - - func fetchAvailableModels() async throws -> [AIModelInfo] { - XAI.subscriptionModelIDs.map { AIModelInfo(id: $0) } - } - - func testConnection() async throws -> Bool { - let testModel = model.isEmpty ? XAI.subscriptionModelIDs[0] : model - let testOptions = ChatTransportOptions(model: testModel) - let testTurn = ChatTurnWire(role: .user, blocks: [.text("Hi")]) - let request = try await buildRequest(turns: [testTurn], options: testOptions, stream: false) - let (data, response) = try await session.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { return false } - if httpResponse.statusCode == 200 || httpResponse.statusCode == 400 { - return true - } - if httpResponse.statusCode == 401 { - throw AIProviderError.authenticationFailed("") - } - let body = String(data: data, encoding: .utf8) ?? "" - throw AIProviderError.mapHTTPError(statusCode: httpResponse.statusCode, body: body) - } - - private func buildRequest( - turns: [ChatTurnWire], - options: ChatTransportOptions, - stream: Bool - ) async throws -> URLRequest { - let accessToken = try await tokenStore.validAccessToken() - guard let url = URL(string: "\(XAI.cliProxyBaseURL)/responses") else { - throw AIProviderError.invalidEndpoint(XAI.cliProxyBaseURL) - } - var request = URLRequest(url: url) - request.httpMethod = "POST" - for (field, value) in Self.requestHeaders(accessToken: accessToken, model: options.model) { - request.setValue(value, forHTTPHeaderField: field) - } - let body = try Self.requestBody(turns: turns, options: options, stream: stream) - request.httpBody = try JSONSerialization.data(withJSONObject: body) - return request - } - - static func requestHeaders(accessToken: String, model: String) -> [String: String] { - var headers = [ - "Content-Type": "application/json", - "Authorization": "Bearer \(accessToken)", - XAI.cliTokenAuthHeader: XAI.cliTokenAuthValue, - XAI.cliClientVersionHeader: XAI.cliClientVersion, - "User-Agent": XAI.userAgent - ] - if !model.isEmpty { - headers[XAI.cliModelOverrideHeader] = model - } - return headers - } - - static func requestBody( - turns: [ChatTurnWire], - options: ChatTransportOptions, - stream: Bool - ) throws -> [String: Any] { - var body: [String: Any] = [ - "model": options.model, - "input": try OpenAIResponsesProvider.encodeInput(turns: turns), - "store": false, - "stream": stream, - "instructions": instructions(for: options) - ] - - if let effort = options.reasoningEffort { - body["reasoning"] = ["effort": effort.xaiReasoningEffort] - } - - if !options.tools.isEmpty { - body["tools"] = try options.tools.map(OpenAIResponsesProvider.encodeToolSpec(_:)) - } - - return body - } - - private static func instructions(for options: ChatTransportOptions) -> String { - if let prompt = options.systemPrompt, !prompt.isEmpty { - return prompt - } - return defaultInstructions - } -} diff --git a/TablePro/Core/AI/XAI/XAIModels.swift b/TablePro/Core/AI/XAI/XAIModels.swift deleted file mode 100644 index 8e84cbe5e3..0000000000 --- a/TablePro/Core/AI/XAI/XAIModels.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// XAIModels.swift -// TablePro -// - -import Foundation - -struct XAITokens: Codable, Equatable, Sendable { - var accessToken: String - var refreshToken: String - var idToken: String - var email: String - var expiresAt: Date - - static let expirySkew: TimeInterval = 60 - - var isExpired: Bool { - Date() >= expiresAt.addingTimeInterval(-Self.expirySkew) - } -} - -struct XAITokenResponse: Sendable { - let accessToken: String - let refreshToken: String - let idToken: String - let expiresAt: Date -} - -protocol XAITokenRefreshing: Sendable { - func refresh(refreshToken: String) async throws -> XAITokenResponse -} diff --git a/TablePro/Core/AI/XAI/XAIOAuthClient.swift b/TablePro/Core/AI/XAI/XAIOAuthClient.swift deleted file mode 100644 index 223e528c5f..0000000000 --- a/TablePro/Core/AI/XAI/XAIOAuthClient.swift +++ /dev/null @@ -1,123 +0,0 @@ -// -// XAIOAuthClient.swift -// TablePro -// - -import Foundation -import os - -final class XAIOAuthClient: XAITokenRefreshing { - private static let logger = Logger(subsystem: "com.TablePro", category: "XAIOAuthClient") - - private let session: URLSession - - init(session: URLSession = URLSession(configuration: .ephemeral)) { - self.session = session - } - - func authorizeURL(pkce: XAIPKCE, redirectURI: String) -> URL? { - var components = URLComponents(string: XAI.authorizeEndpoint) - components?.queryItems = [ - URLQueryItem(name: "response_type", value: "code"), - URLQueryItem(name: "client_id", value: XAI.clientID), - URLQueryItem(name: "redirect_uri", value: redirectURI), - URLQueryItem(name: "scope", value: XAI.scope), - URLQueryItem(name: "code_challenge", value: pkce.challenge), - URLQueryItem(name: "code_challenge_method", value: "S256"), - URLQueryItem(name: "state", value: pkce.state) - ] - return components?.url - } - - func exchangeCode(code: String, verifier: String, redirectURI: String) async throws -> XAITokenResponse { - let form = [ - "grant_type": "authorization_code", - "code": code, - "redirect_uri": redirectURI, - "client_id": XAI.clientID, - "code_verifier": verifier - ] - return try await postToken(form: form, fallbackRefreshToken: "") - } - - func refresh(refreshToken: String) async throws -> XAITokenResponse { - let form = [ - "grant_type": "refresh_token", - "refresh_token": refreshToken, - "client_id": XAI.clientID - ] - return try await postToken(form: form, fallbackRefreshToken: refreshToken) - } - - func revoke(refreshToken: String) async { - guard !refreshToken.isEmpty, let url = URL(string: XAI.revokeEndpoint) else { return } - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") - request.httpBody = Self.formBody([ - "token": refreshToken, - "client_id": XAI.clientID - ]) - _ = try? await session.data(for: request) - } - - private func postToken( - form: [String: String], - fallbackRefreshToken: String - ) async throws -> XAITokenResponse { - guard let url = URL(string: XAI.tokenEndpoint) else { - throw AIProviderError.invalidEndpoint(XAI.tokenEndpoint) - } - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") - request.httpBody = Self.formBody(form) - - let data: Data - let response: URLResponse - do { - (data, response) = try await session.data(for: request) - } catch { - Self.logger.warning("xAI token request failed: \(error.publicLogShape, privacy: .public)") - throw AIProviderError.networkError(error.localizedDescription) - } - - guard let httpResponse = response as? HTTPURLResponse else { - throw AIProviderError.networkError("Invalid response") - } - guard httpResponse.statusCode == 200 else { - let body = String(data: data, encoding: .utf8) ?? "" - throw AIProviderError.mapHTTPError( - statusCode: httpResponse.statusCode, - body: body, - treatForbiddenAsAuthFailure: true - ) - } - return try Self.parseTokenResponse(data, fallbackRefreshToken: fallbackRefreshToken) - } - - static func parseTokenResponse( - _ data: Data, - fallbackRefreshToken: String - ) throws -> XAITokenResponse { - guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let accessToken = json["access_token"] as? String, !accessToken.isEmpty else { - throw AIProviderError.authenticationFailed(String(localized: "xAI did not return an access token.")) - } - let idToken = json["id_token"] as? String ?? "" - let refreshToken = json["refresh_token"] as? String ?? fallbackRefreshToken - let expiresIn = json["expires_in"] as? Double ?? 3_600 - return XAITokenResponse( - accessToken: accessToken, - refreshToken: refreshToken, - idToken: idToken, - expiresAt: Date().addingTimeInterval(expiresIn) - ) - } - - private static func formBody(_ form: [String: String]) -> Data { - var components = URLComponents() - components.queryItems = form.map { URLQueryItem(name: $0.key, value: $0.value) } - return components.percentEncodedQuery?.data(using: .utf8) ?? Data() - } -} diff --git a/TablePro/Core/AI/XAI/XAIPKCE.swift b/TablePro/Core/AI/XAI/XAIPKCE.swift deleted file mode 100644 index 527a27e880..0000000000 --- a/TablePro/Core/AI/XAI/XAIPKCE.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// XAIPKCE.swift -// TablePro -// - -import CryptoKit -import Foundation -import Security - -struct XAIPKCE: Sendable { - let verifier: String - let challenge: String - let state: String - - init() { - let verifier = Self.randomURLSafeString(byteCount: 32) - self.verifier = verifier - self.state = Self.randomURLSafeString(byteCount: 32) - let digest = SHA256.hash(data: Data(verifier.utf8)) - self.challenge = XAIBase64URL.encode(Data(digest)) - } - - static func randomURLSafeString(byteCount: Int) -> String { - var bytes = [UInt8](repeating: 0, count: byteCount) - if SecRandomCopyBytes(kSecRandomDefault, byteCount, &bytes) != errSecSuccess { - var generator = SystemRandomNumberGenerator() - for index in bytes.indices { - bytes[index] = UInt8.random(in: .min ... .max, using: &generator) - } - } - return XAIBase64URL.encode(Data(bytes)) - } -} diff --git a/TablePro/Core/AI/XAI/XAIService.swift b/TablePro/Core/AI/XAI/XAIService.swift deleted file mode 100644 index fca093d1e5..0000000000 --- a/TablePro/Core/AI/XAI/XAIService.swift +++ /dev/null @@ -1,102 +0,0 @@ -// -// XAIService.swift -// TablePro -// - -import AppKit -import Combine -import Foundation -import os - -@MainActor -final class XAIService: ObservableObject { - static let shared = XAIService() - - nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "XAIService") - - enum AuthState: Sendable, Equatable { - case signedOut - case signingIn - case signedIn(email: String) - - var isSignedIn: Bool { - if case .signedIn = self { return true } - return false - } - } - - @Published private(set) var authState: AuthState = .signedOut - @Published private(set) var errorMessage: String? - - private let tokenStore: XAITokenStore - private let oauthClient: XAIOAuthClient - - init( - tokenStore: XAITokenStore = .shared, - oauthClient: XAIOAuthClient = XAIOAuthClient() - ) { - self.tokenStore = tokenStore - self.oauthClient = oauthClient - } - - func refreshAuthState() async { - if let tokens = await tokenStore.currentTokens() { - authState = .signedIn(email: tokens.email) - } else { - authState = .signedOut - } - } - - func signIn() async { - errorMessage = nil - authState = .signingIn - - let pkce = XAIPKCE() - let server = XAICallbackServer(expectedState: pkce.state) - do { - try await server.start() - let redirectURI = server.redirectURI - guard let authorizeURL = oauthClient.authorizeURL(pkce: pkce, redirectURI: redirectURI) else { - server.stop() - failSignIn(AIProviderError.invalidEndpoint(XAI.authorizeEndpoint)) - return - } - NSWorkspace.shared.open(authorizeURL) - let code = try await server.waitForCode() - let response = try await oauthClient.exchangeCode( - code: code, - verifier: pkce.verifier, - redirectURI: redirectURI - ) - let email = XAIIdentity.email(fromIDToken: response.idToken) - let tokens = XAITokens( - accessToken: response.accessToken, - refreshToken: response.refreshToken, - idToken: response.idToken, - email: email, - expiresAt: response.expiresAt - ) - await tokenStore.save(tokens) - authState = .signedIn(email: email) - Self.logger.info("xAI sign-in succeeded") - } catch { - server.stop() - failSignIn(error) - } - } - - func signOut() async { - if let tokens = await tokenStore.currentTokens() { - await oauthClient.revoke(refreshToken: tokens.refreshToken) - } - await tokenStore.clear() - authState = .signedOut - errorMessage = nil - } - - private func failSignIn(_ error: Error) { - Self.logger.error("xAI sign-in failed: \(error.publicLogShape, privacy: .public)") - errorMessage = error.localizedDescription - authState = .signedOut - } -} diff --git a/TablePro/Core/AI/XAI/XAITokenStore.swift b/TablePro/Core/AI/XAI/XAITokenStore.swift deleted file mode 100644 index a2cec775c9..0000000000 --- a/TablePro/Core/AI/XAI/XAITokenStore.swift +++ /dev/null @@ -1,127 +0,0 @@ -// -// XAITokenStore.swift -// TablePro -// - -import Foundation -import os - -actor XAITokenStore { - static let shared = XAITokenStore() - - private static let logger = Logger(subsystem: "com.TablePro", category: "XAITokenStore") - private static let storageKey = "com.TablePro.aioauth.xai" - - private let keychain: KeychainStoring - private let refresher: XAITokenRefreshing - private var cached: XAITokens? - private var refreshTask: Task? - - init( - keychain: KeychainStoring = AppStorageEnvironment.shared.keychain, - refresher: XAITokenRefreshing = XAIOAuthClient() - ) { - self.keychain = keychain - self.refresher = refresher - } - - func currentTokens() -> XAITokens? { - loadTokens() - } - - func email() -> String? { - loadTokens()?.email - } - - func isSignedIn() -> Bool { - loadTokens() != nil - } - - func save(_ tokens: XAITokens) { - persist(tokens) - } - - func clear() { - cached = nil - refreshTask?.cancel() - refreshTask = nil - keychain.delete(forKey: Self.storageKey) - } - - func validAccessToken() async throws -> String { - guard let tokens = loadTokens() else { - throw AIProviderError.authenticationFailed(String(localized: "Not signed in to xAI.")) - } - if !tokens.isExpired { - return tokens.accessToken - } - return try await refresh(using: tokens.refreshToken).accessToken - } - - func forceRefresh() async throws -> String { - guard let tokens = loadTokens() else { - throw AIProviderError.authenticationFailed(String(localized: "Not signed in to xAI.")) - } - return try await refresh(using: tokens.refreshToken).accessToken - } - - private func refresh(using refreshToken: String) async throws -> XAITokens { - if let refreshTask { - return try await refreshTask.value - } - let task = Task { - let response = try await refresher.refresh(refreshToken: refreshToken) - return persistRefreshed(response) - } - refreshTask = task - defer { refreshTask = nil } - do { - return try await task.value - } catch { - if case AIProviderError.authenticationFailed = error { - Self.logger.notice("xAI refresh rejected; clearing stored session") - clear() - } - throw error - } - } - - private func persistRefreshed(_ response: XAITokenResponse) -> XAITokens { - let previous = loadTokens() - let email = response.idToken.isEmpty - ? (previous?.email ?? "") - : XAIIdentity.email(fromIDToken: response.idToken) - let tokens = XAITokens( - accessToken: response.accessToken, - refreshToken: response.refreshToken.isEmpty ? (previous?.refreshToken ?? "") : response.refreshToken, - idToken: response.idToken.isEmpty ? (previous?.idToken ?? "") : response.idToken, - email: email.isEmpty ? (previous?.email ?? "") : email, - expiresAt: response.expiresAt - ) - persist(tokens) - return tokens - } - - private func persist(_ tokens: XAITokens) { - cached = tokens - guard let data = try? JSONEncoder().encode(tokens), - let json = String(data: data, encoding: .utf8) else { - Self.logger.error("Failed to encode xAI tokens for Keychain") - return - } - keychain.writeString(json, forKey: Self.storageKey) - } - - private func loadTokens() -> XAITokens? { - if let cached { - return cached - } - guard case .found(let json) = keychain.readStringResult(forKey: Self.storageKey), - let data = json.data(using: .utf8), - let tokens = try? JSONDecoder().decode(XAITokens.self, from: data) else { - return nil - } - cached = tokens - return tokens - } -} diff --git a/TablePro/Models/AI/AIModels.swift b/TablePro/Models/AI/AIModels.swift index 0309ed0314..424c6c46dd 100644 --- a/TablePro/Models/AI/AIModels.swift +++ b/TablePro/Models/AI/AIModels.swift @@ -10,7 +10,6 @@ import Foundation enum AIProviderType: String, Codable, CaseIterable, Identifiable, Sendable { case copilot case chatgptCodex - case cursor case claude case claudeAgent case openAI @@ -29,7 +28,6 @@ enum AIProviderType: String, Codable, CaseIterable, Identifiable, Sendable { switch self { case .copilot: return "GitHub Copilot" case .chatgptCodex: return "ChatGPT" - case .cursor: return "Cursor" case .claude: return "Claude" case .claudeAgent: return "Claude Agent" case .openAI: return "OpenAI" @@ -48,7 +46,6 @@ enum AIProviderType: String, Codable, CaseIterable, Identifiable, Sendable { switch self { case .copilot: return "" case .chatgptCodex: return "" - case .cursor: return "" case .claude: return "https://api.anthropic.com" case .claudeAgent: return "" case .openAI: return "https://api.openai.com" @@ -73,13 +70,12 @@ enum AIProviderType: String, Codable, CaseIterable, Identifiable, Sendable { switch self { 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 .xai: return .apiKey case .ollama: return .none case .llamaCpp: return .none case .mlx: return .none @@ -100,7 +96,7 @@ enum AIProviderType: String, Codable, CaseIterable, Identifiable, Sendable { return .gemini case .ollama: return .ollama - case .copilot, .chatgptCodex, .cursor, .claudeAgent, .openRouter, .llamaCpp, .mlx, .openCode, .custom: + case .copilot, .chatgptCodex, .claudeAgent, .openRouter, .llamaCpp, .mlx, .openCode, .custom: return .chatCompletions } } @@ -112,7 +108,6 @@ enum AIProviderType: String, Codable, CaseIterable, Identifiable, Sendable { switch self { case .copilot: return "chevron.left.forwardslash.chevron.right" case .chatgptCodex: return "bubble.left.and.bubble.right" - case .cursor: return "cursorarrow" case .claude: return "brain" case .claudeAgent: return "terminal" case .openAI: return "cpu" diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index e0664fe551..6058d31951 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -15508,46 +15508,6 @@ } } }, - "A key from the xAI Console bills xAI API credits. Grok 4.5 and Grok 4.3 are available." : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Une clé de la console xAI consomme des crédits de l’API xAI. Grok 4.5 et Grok 4.3 sont disponibles." - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "xAI Console 키를 사용하면 xAI API 크레딧이 차감됩니다. Grok 4.5와 Grok 4.3을 사용할 수 있습니다." - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "xAI Console'dan alınan bir anahtar xAI API kredilerinden düşer. Grok 4.5 ve Grok 4.3 kullanılabilir." - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Khóa lấy từ xAI Console sẽ tính phí vào tín dụng API của xAI. Có sẵn Grok 4.5 và Grok 4.3." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "使用 xAI Console 的密钥会扣除 xAI API 额度。可用 Grok 4.5 和 Grok 4.3。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "使用 xAI Console 的金鑰會扣除 xAI API 額度。可使用 Grok 4.5 和 Grok 4.3。" - } - } - } - }, "A key needs a name." : { "extractionState" : "manual", "generatesSymbol" : false, @@ -49726,46 +49686,6 @@ } } }, - "Copy install command" : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Copier la commande d’installation" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "설치 명령 복사" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kurulum komutunu kopyala" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sao chép lệnh cài đặt" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "复制安装命令" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "複製安裝指令" - } - } - } - }, "Copy JSON" : { "localizations" : { "fr" : { @@ -52848,46 +52768,6 @@ } } }, - "Could not start the sign-in listener. Please try again." : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Impossible de démarrer l’écouteur de connexion. Réessayez." - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "로그인 리스너를 시작할 수 없습니다. 다시 시도하십시오." - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Oturum açma dinleyicisi başlatılamadı. Lütfen tekrar deneyin." - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Không thể khởi động trình lắng nghe đăng nhập. Vui lòng thử lại." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "无法启动登录监听器。请重试。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "無法啟動登入監聽器。請再試一次。" - } - } - } - }, "Could not write %@." : { "localizations" : { "fr" : { @@ -55470,46 +55350,6 @@ } } }, - "Cursor CLI failed: %@" : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Échec de la CLI Cursor : %@" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cursor CLI 실패: %@" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cursor CLI başarısız oldu: %@" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cursor CLI thất bại: %@" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cursor CLI 失败:%@" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cursor CLI 失敗:%@" - } - } - } - }, "Cursor position %d exceeds SQL length (%d)" : { "localizations" : { "en" : { @@ -55603,46 +55443,6 @@ } } }, - "Cursor sign-in failed." : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Échec de la connexion à Cursor." - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cursor 로그인에 실패했습니다." - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cursor girişi başarısız oldu." - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Đăng nhập Cursor thất bại." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cursor 登录失败。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cursor 登入失敗。" - } - } - } - }, "CURTIME()" : { "extractionState" : "stale", "localizations" : { @@ -120282,46 +120082,6 @@ } } }, - "Not signed in to Cursor. Sign in first." : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Non connecté à Cursor. Connectez-vous d’abord." - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cursor에 로그인되지 않았습니다. 먼저 로그인하십시오." - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cursor'a giriş yapılmadı. Önce giriş yapın." - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Chưa đăng nhập Cursor. Hãy đăng nhập trước." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "未登录 Cursor。请先登录。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "尚未登入 Cursor。請先登入。" - } - } - } - }, "Not signed in to GitHub Copilot" : { "localizations" : { "fr" : { @@ -120364,46 +120124,6 @@ }, "Not Signed In to iCloud" : { - }, - "Not signed in to xAI." : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Non connecté à xAI." - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "xAI에 로그인되지 않았습니다." - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "xAI'da oturum açılmamış." - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Chưa đăng nhập xAI." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "尚未登录 xAI。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "尚未登入 xAI。" - } - } - } }, "Not started" : { "localizations" : { @@ -126734,46 +126454,6 @@ } } }, - "Optional. A key from the Cursor dashboard is used instead of signing in." : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Facultatif. Une clé du tableau de bord Cursor est utilisée au lieu de la connexion." - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "선택 사항입니다. 로그인하는 대신 Cursor 대시보드에서 발급한 키를 사용합니다." - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "İsteğe bağlı. Giriş yapmak yerine Cursor panosundan bir anahtar kullanılır." - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tùy chọn. Một khóa từ bảng điều khiển Cursor được dùng thay cho đăng nhập." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "可选。使用来自 Cursor 仪表板的密钥,而不是登录。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "選用。使用來自 Cursor 儀表板的金鑰,而非登入。" - } - } - } - }, "Optional. Set this to reach a database that only listens on a Unix socket." : { "localizations" : { "fr" : { @@ -164149,46 +163829,6 @@ } } }, - "Sign in with Cursor" : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Se connecter avec Cursor" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cursor로 로그인" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cursor ile giriş yap" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Đăng nhập bằng Cursor" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "使用 Cursor 登录" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "使用 Cursor 登入" - } - } - } - }, "Sign in with GitHub" : { "localizations" : { "fr" : { @@ -164229,46 +163869,6 @@ } } }, - "Sign in with xAI" : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Se connecter avec xAI" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "xAI로 로그인" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "xAI ile giriş yap" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Đăng nhập bằng xAI" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "使用 xAI 登录" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "使用 xAI 登入" - } - } - } - }, "Sign in with your Apple Account in System Settings to sync connections and settings between your Macs." : { }, @@ -164702,86 +164302,6 @@ } } }, - "Signed in with Cursor" : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Connecté avec Cursor" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cursor로 로그인됨" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cursor ile giriş yapıldı" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Đã đăng nhập bằng Cursor" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "已使用 Cursor 登录" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "已使用 Cursor 登入" - } - } - } - }, - "Signed in with xAI" : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Connecté avec xAI" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "xAI로 로그인됨" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "xAI ile oturum açıldı" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Đã đăng nhập bằng xAI" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "已使用 xAI 登录" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "已使用 xAI 登入" - } - } - } - }, "Signed in." : { "localizations" : { "fr" : { @@ -182610,86 +182130,6 @@ } } }, - "The Cursor CLI is not installed." : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Le CLI Cursor n’est pas installé." - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cursor CLI가 설치되어 있지 않습니다." - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cursor CLI yüklü değil." - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cursor CLI chưa được cài đặt." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "未安装 Cursor CLI。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "尚未安裝 Cursor CLI。" - } - } - } - }, - "The Cursor CLI is not installed. Install it, then sign in." : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Le CLI Cursor n’est pas installé. Installez-le, puis connectez-vous." - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cursor CLI가 설치되어 있지 않습니다. 설치한 다음 로그인하십시오." - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cursor CLI yüklü değil. Yükleyin, sonra giriş yapın." - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cursor CLI chưa được cài đặt. Hãy cài đặt rồi đăng nhập." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "未安装 Cursor CLI。请先安装,然后登录。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "尚未安裝 Cursor CLI。請先安裝,然後登入。" - } - } - } - }, "The Dameng connection is closed." : { "extractionState" : "manual", "generatesSymbol" : false, @@ -205122,86 +204562,6 @@ } } }, - "Use your Cursor subscription with no API key. Requires the Cursor CLI." : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Utilisez votre abonnement Cursor sans clé API. Nécessite la CLI Cursor." - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "API 키 없이 Cursor 구독을 사용합니다. Cursor CLI가 필요합니다." - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "API anahtarı olmadan Cursor aboneliğinizi kullanın. Cursor CLI gerektirir." - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dùng gói đăng ký Cursor của bạn mà không cần khóa API. Yêu cầu Cursor CLI." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "无需 API 密钥即可使用你的 Cursor 订阅。需要 Cursor CLI。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "無需 API 金鑰即可使用你的 Cursor 訂閱。需要 Cursor CLI。" - } - } - } - }, - "Use your SuperGrok or X Premium+ subscription with no API key. Sign-in opens the Grok Build consent screen. This is an unofficial interface that may change." : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Utilisez votre abonnement SuperGrok ou X Premium+ sans clé API. La connexion ouvre l’écran de consentement Grok Build. Il s’agit d’une interface non officielle susceptible de changer." - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "API 키 없이 SuperGrok 또는 X Premium+ 구독을 사용합니다. 로그인하면 Grok Build 동의 화면이 열립니다. 이는 변경될 수 있는 비공식 인터페이스입니다." - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "API anahtarı olmadan SuperGrok veya X Premium+ aboneliğinizi kullanın. Oturum açma, Grok Build onay ekranını açar. Bu, değişebilecek resmi olmayan bir arayüzdür." - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dùng gói SuperGrok hoặc X Premium+ của bạn mà không cần khóa API. Khi đăng nhập sẽ mở màn hình đồng ý của Grok Build. Đây là giao diện không chính thức và có thể thay đổi." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "无需 API 密钥即可使用你的 SuperGrok 或 X Premium+ 订阅。登录时会打开 Grok Build 的授权页面。这是非官方接口,可能会变动。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "無需 API 金鑰即可使用你的 SuperGrok 或 X Premium+ 訂閱。登入時會開啟 Grok Build 的授權畫面。這是非官方介面,可能會變動。" - } - } - } - }, "Used by %lld" : { "localizations" : { "fr" : { @@ -211686,46 +211046,6 @@ } } }, - "xAI did not return an access token." : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "xAI n’a pas renvoyé de jeton d’accès." - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "xAI에서 접근 토큰을 반환하지 않았습니다." - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "xAI bir erişim belirteci döndürmedi." - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "xAI không trả về access token." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "xAI 未返回访问令牌。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "xAI 未回傳存取權杖。" - } - } - } - }, "XXXXX-XXXXX-XXXXX-XXXXX-XXXXX" : { "extractionState" : "stale", "localizations" : { diff --git a/TablePro/Views/Settings/AIProviderDetailSheet.swift b/TablePro/Views/Settings/AIProviderDetailSheet.swift index a6c4efc3f3..5ad9cb08bf 100644 --- a/TablePro/Views/Settings/AIProviderDetailSheet.swift +++ b/TablePro/Views/Settings/AIProviderDetailSheet.swift @@ -34,11 +34,8 @@ struct AIProviderDetailSheet: View { @ObservedObject private var chatGPTCodexService = ChatGPTCodexService.shared - @ObservedObject private var cursorAgentService = CursorAgentService.shared @ObservedObject private var claudeAgentService = ClaudeAgentService.shared - @ObservedObject private var xaiService = XAIService.shared - @State private var showRemoveConfirmation = false enum TestResult: Equatable { @@ -106,15 +103,9 @@ struct AIProviderDetailSheet: View { if draft.type == .chatgptCodex { Task { await chatGPTCodexService.refreshAuthState() } } - if draft.type == .cursor { - Task { await cursorAgentService.refreshStatus() } - } if draft.type == .claudeAgent { Task { await claudeAgentService.performRefresh() } } - if draft.type == .xai { - Task { await xaiService.refreshAuthState() } - } fetchModels() } } @@ -171,13 +162,7 @@ struct AIProviderDetailSheet: View { private var authSection: some View { switch draft.authStyle { case .apiKey, .optionalApiKey: - if draft.type == .cursor { - cursorAuthSection - } else if draft.type == .xai { - xaiAuthSection - } else { - apiKeyAuthSection - } + apiKeyAuthSection case .oauth: switch descriptor?.oauthFlowKind { case .deviceCode: @@ -270,243 +255,6 @@ struct AIProviderDetailSheet: View { } } - @ViewBuilder - private var cursorAuthSection: some View { - cursorAPIKeySection - cursorSignInSection - } - - private var cursorAPIKeySection: some View { - Section { - SecureField(String(localized: "API Key"), text: $apiKey) - .onChange(of: apiKey) { _ in - scheduleFetchModels() - testResult = nil - } - HStack { - Spacer() - Button { - testProvider() - } label: { - HStack(spacing: 6) { - if isTesting { ProgressView().controlSize(.small) } - Text("Test Connection") - } - } - .disabled(isTesting || apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - if case .success = testResult { - Label(String(localized: "Connection successful"), systemImage: "checkmark.circle.fill") - .foregroundStyle(.green) - .font(.caption) - } else if case .failure(let message) = testResult { - Label(message, systemImage: "xmark.circle.fill") - .foregroundStyle(.red) - .font(.caption) - .lineLimit(3) - } - } header: { - Text("API Key") - } footer: { - Text("Optional. A key from the Cursor dashboard is used instead of signing in.") - .font(.caption) - .foregroundStyle(.secondary) - } - } - - @ViewBuilder - private var cursorSignInSection: some View { - Section { - cursorSignInContent - if let message = cursorAgentService.errorMessage { - Label(message, systemImage: "exclamationmark.triangle.fill") - .foregroundStyle(.orange) - .font(.caption) - .lineLimit(3) - } - } header: { - Text("Sign in with Cursor") - } footer: { - Text("Use your Cursor subscription with no API key. Requires the Cursor CLI.") - .font(.caption) - .foregroundStyle(.secondary) - } - } - - @ViewBuilder - private var cursorSignInContent: some View { - switch cursorAgentService.authState { - case .notInstalled: - LabeledContent { - Button { - copyToPasteboard(CursorAgentCLI.installCommand) - } label: { - Image(systemName: "doc.on.doc") - } - .buttonStyle(.borderless) - .help(String(localized: "Copy install command")) - .accessibilityLabel(String(localized: "Copy install command")) - } label: { - Text(CursorAgentCLI.installCommand) - .font(.system(.body, design: .monospaced)) - .textSelection(.enabled) - .lineLimit(1) - .truncationMode(.middle) - } - HStack { - Text("The Cursor CLI is not installed.") - .font(.caption) - .foregroundStyle(.secondary) - Spacer() - Button(String(localized: "Recheck")) { - Task { await cursorAgentService.refreshStatus() } - } - .controlSize(.small) - } - - case .signedOut: - HStack { - Text("Not signed in") - .foregroundStyle(.secondary) - Spacer() - Button(String(localized: "Sign in with Cursor")) { - cursorAgentService.signIn() - } - .buttonStyle(.borderedProminent) - } - - case .signingIn: - HStack(spacing: 8) { - ProgressView().controlSize(.small) - Text("Opening your browser to sign in…") - .font(.caption) - .foregroundStyle(.secondary) - Spacer() - Button(String(localized: "Cancel")) { - cursorAgentService.cancelSignIn() - } - .controlSize(.small) - } - - case .signedIn(let account): - LabeledContent { - Button(String(localized: "Sign Out")) { - Task { await cursorAgentService.signOut() } - } - } label: { - Label( - account.isEmpty - ? String(localized: "Signed in") - : String(format: String(localized: "Signed in as %@"), account), - systemImage: "checkmark.circle.fill" - ) - .foregroundStyle(.green) - } - } - } - - @ViewBuilder - private var xaiAuthSection: some View { - xaiAPIKeySection - xaiSignInSection - } - - private var xaiAPIKeySection: some View { - Section { - SecureField(String(localized: "API Key"), text: $apiKey) - .onChange(of: apiKey) { _ in - scheduleFetchModels() - testResult = nil - } - HStack { - Spacer() - Button { - testProvider() - } label: { - HStack(spacing: 6) { - if isTesting { ProgressView().controlSize(.small) } - Text("Test Connection") - } - } - .disabled(isTesting || apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - if case .success = testResult { - Label(String(localized: "Connection successful"), systemImage: "checkmark.circle.fill") - .foregroundStyle(.green) - .font(.caption) - } else if case .failure(let message) = testResult { - Label(message, systemImage: "xmark.circle.fill") - .foregroundStyle(.red) - .font(.caption) - .lineLimit(3) - } - } header: { - Text("API Key") - } footer: { - Text("A key from the xAI Console bills xAI API credits. Grok 4.5 and Grok 4.3 are available.") - .font(.caption) - .foregroundStyle(.secondary) - } - } - - @ViewBuilder - private var xaiSignInSection: some View { - Section { - xaiSignInContent - if let message = xaiService.errorMessage { - Label(message, systemImage: "exclamationmark.triangle.fill") - .foregroundStyle(.orange) - .font(.caption) - .lineLimit(3) - } - } header: { - Text("Sign in with xAI") - } footer: { - Text("Use your SuperGrok or X Premium+ subscription with no API key. Sign-in opens the Grok Build consent screen. This is an unofficial interface that may change.") - .font(.caption) - .foregroundStyle(.secondary) - } - } - - @ViewBuilder - private var xaiSignInContent: some View { - switch xaiService.authState { - case .signedOut: - HStack { - Text("Not signed in") - .foregroundStyle(.secondary) - Spacer() - Button(String(localized: "Sign in with xAI")) { - Task { await xaiService.signIn() } - } - .buttonStyle(.borderedProminent) - } - - case .signingIn: - HStack(spacing: 8) { - ProgressView().controlSize(.small) - Text("Opening your browser to sign in…") - .font(.caption) - .foregroundStyle(.secondary) - } - - case .signedIn(let email): - LabeledContent { - Button(String(localized: "Sign Out")) { - Task { await xaiService.signOut() } - } - } label: { - Label( - email.isEmpty - ? String(localized: "Signed in") - : String(format: String(localized: "Signed in as %@"), email), - systemImage: "checkmark.circle.fill" - ) - .foregroundStyle(.green) - } - } - } - private func copyToPasteboard(_ string: String) { NSPasteboard.general.clearContents() NSPasteboard.general.setString(string, forType: .string) @@ -759,15 +507,7 @@ struct AIProviderDetailSheet: View { } private var allowsEndpointField: Bool { - descriptor?.allowsEndpointConfiguration == true && endpointDrivesRequests - } - - /// xAI reaches its own proxy rather than the configured base while it is running on a - /// subscription sign-in, so the field and the URL under it would both describe a request the - /// app is not going to make. - private var endpointDrivesRequests: Bool { - guard draft.type == .xai else { return true } - return !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + descriptor?.allowsEndpointConfiguration == true } private var shouldShowConnectionSection: Bool { diff --git a/TablePro/Views/Settings/AISettingsView.swift b/TablePro/Views/Settings/AISettingsView.swift index a6f56f7479..46ac04f2fe 100644 --- a/TablePro/Views/Settings/AISettingsView.swift +++ b/TablePro/Views/Settings/AISettingsView.swift @@ -15,8 +15,6 @@ struct AISettingsView: View { @State private var addingProvider: AIProviderConfig? @State private var pendingDeleteID: UUID? @ObservedObject private var chatGPTCodexService = ChatGPTCodexService.shared - @ObservedObject private var cursorAgentService = CursorAgentService.shared - @ObservedObject private var xaiService = XAIService.shared @ObservedObject private var claudeAgentService = ClaudeAgentService.shared @State private var providersWithKey: Set = [] @@ -37,8 +35,6 @@ struct AISettingsView: View { .scrollContentBackground(.hidden) .task { refreshKeyAvailability() } .task { await chatGPTCodexService.refreshAuthState() } - .task { await cursorAgentService.refreshStatus() } - .task { await xaiService.refreshAuthState() } .task { await claudeAgentService.performRefresh() } .onChange(of: settings.providers.map(\.id)) { _ in refreshKeyAvailability() @@ -353,12 +349,6 @@ struct AISettingsView: View { if provider.type == .custom { return customStatusText(for: provider) } - if provider.type == .cursor { - return cursorStatusText(for: provider) - } - if provider.type == .xai { - return xaiStatusText(for: provider) - } return apiKeyStatusText(for: provider) case .none: if provider.type == .claudeAgent { @@ -392,26 +382,6 @@ struct AISettingsView: View { } } - private func cursorStatusText(for provider: AIProviderConfig) -> String { - if providersWithKey.contains(provider.id) { - return String(localized: "API key set") - } - if cursorAgentService.authState.isSignedIn { - return String(localized: "Signed in with Cursor") - } - return String(localized: "Not configured") - } - - private func xaiStatusText(for provider: AIProviderConfig) -> String { - if providersWithKey.contains(provider.id) { - return String(localized: "API key set") - } - if xaiService.authState.isSignedIn { - return String(localized: "Signed in with xAI") - } - return String(localized: "Not configured") - } - /// Claude Agent authenticates through the CLI rather than an endpoint or a key, so the endpoint /// arm this type used to fall into could only ever answer "Not configured", however well the /// provider was working. The sheet has always read the service; this row now reads the same one. diff --git a/TableProTests/Accessibility/AccessibleControlNameTests.swift b/TableProTests/Accessibility/AccessibleControlNameTests.swift index 2f345f18b1..86c69cdf61 100644 --- a/TableProTests/Accessibility/AccessibleControlNameTests.swift +++ b/TableProTests/Accessibility/AccessibleControlNameTests.swift @@ -35,7 +35,6 @@ struct AccessibleControlNameTests { ("TablePro/Views/Structure/CreateTableView.swift", "Add Row"), ("TablePro/Views/Structure/CreateTableView.swift", "Delete Selected"), ("TablePro/Views/ServerDashboard/DashboardToolbarView.swift", "Refresh Now"), - ("TablePro/Views/Settings/AIProviderDetailSheet.swift", "Copy install command"), ("TablePro/Views/Settings/LinkedFoldersSection.swift", "folder.name"), ("TablePro/Views/UsersRoles/PrivilegeChecklistView.swift", "Bulk actions"), ("Packages/TableProEditor/Sources/TableProEditorKit/Find/PanelView/FindControls.swift", "Previous Match"), diff --git a/TableProTests/Core/AI/CursorAgentProviderTests.swift b/TableProTests/Core/AI/CursorAgentProviderTests.swift deleted file mode 100644 index 1a8b01babe..0000000000 --- a/TableProTests/Core/AI/CursorAgentProviderTests.swift +++ /dev/null @@ -1,84 +0,0 @@ -// -// CursorAgentProviderTests.swift -// TableProTests -// - -import Foundation -@testable import TablePro -import Testing - -struct CursorAgentProviderTests { - @Test("Inference arguments stream JSON, pass model and workspace, and end with the prompt") - func inferenceArgumentsFull() { - let args = CursorAgentProvider.inferenceArguments( - prompt: "count the users", - model: "composer-2", - workspace: "/tmp/ws" - ) - #expect(args.contains("-p")) - #expect(args.contains("stream-json")) - #expect(args.contains("--stream-partial-output")) - #expect(args.contains("--trust")) - #expect(args.contains("--workspace")) - #expect(args.contains("/tmp/ws")) - #expect(args.contains("--model")) - #expect(args.contains("composer-2")) - #expect(args.last == "count the users") - #expect(args.dropLast().last == "--", "Prompt must be guarded by -- to prevent argv flag smuggling") - } - - @Test("A prompt that looks like a flag stays a positional argument") - func promptThatLooksLikeFlagIsNotParsedAsOption() { - let args = CursorAgentProvider.inferenceArguments(prompt: "--yolo --workspace /etc", model: "", workspace: nil) - #expect(args.last == "--yolo --workspace /etc") - #expect(args.dropLast().last == "--") - } - - @Test("Inference arguments omit model and workspace when not provided") - func inferenceArgumentsMinimal() { - let args = CursorAgentProvider.inferenceArguments(prompt: "hi", model: "", workspace: nil) - #expect(!args.contains("--model")) - #expect(!args.contains("--workspace")) - #expect(args.last == "hi") - } - - @Test("Assistant text is read from message.content[0].text") - func assistantTextParsing() { - let event: [String: Any] = [ - "type": "assistant", - "message": ["content": [["text": "SELECT count(*) FROM users"]]] - ] - #expect(CursorAgentProvider.assistantText(event) == "SELECT count(*) FROM users") - } - - @Test("Non-assistant events have no assistant text") - func resultEventHasNoText() { - let result: [String: Any] = ["type": "result", "duration_ms": 1_234] - #expect(CursorAgentProvider.assistantText(result) == nil) - } - - @Test("An assistant event with a timestamp yields incremental text") - func incrementalTextFromTimestampedDelta() { - let event: [String: Any] = [ - "type": "assistant", - "timestamp_ms": 1_234_567, - "message": ["content": [["text": "SELECT 1"]]] - ] - #expect(CursorAgentProvider.incrementalText(event) == "SELECT 1") - } - - @Test("The consolidated final assistant message (no timestamp) is skipped to avoid duplication") - func incrementalTextSkipsConsolidatedFinalMessage() { - let finalMessage: [String: Any] = [ - "type": "assistant", - "message": ["content": [["text": "SELECT 1"]]] - ] - #expect(CursorAgentProvider.incrementalText(finalMessage) == nil) - } - - @Test("Non-assistant events yield no incremental text") - func incrementalTextSkipsNonAssistant() { - let result: [String: Any] = ["type": "result", "timestamp_ms": 1, "duration_ms": 1_234] - #expect(CursorAgentProvider.incrementalText(result) == nil) - } -} diff --git a/TableProTests/Core/AI/CursorProviderEncodingTests.swift b/TableProTests/Core/AI/CursorProviderEncodingTests.swift deleted file mode 100644 index 02d029f1dd..0000000000 --- a/TableProTests/Core/AI/CursorProviderEncodingTests.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// CursorProviderEncodingTests.swift -// TableProTests -// - -import Foundation -@testable import TablePro -import Testing - -struct CursorProviderEncodingTests { - @Test("Prompt renders the system prompt and the role-tagged conversation") - func renderPrompt() { - let options = ChatTransportOptions(model: "composer-2", systemPrompt: "You are a SQL helper.") - let turns = [ - ChatTurnWire(role: .user, blocks: [.text("List the users")]), - ChatTurnWire(role: .assistant, blocks: [.text("SELECT * FROM users")]), - ChatTurnWire(role: .user, blocks: [.text("Count them")]) - ] - let prompt = CursorProvider.renderPrompt(turns: turns, options: options) - #expect(prompt.contains("You are a SQL helper.")) - #expect(prompt.contains("User: List the users")) - #expect(prompt.contains("Assistant: SELECT * FROM users")) - #expect(prompt.contains("User: Count them")) - } - - @Test("Launch body carries prompt text and model id") - func launchBodyWithModel() { - let body = CursorProvider.launchBody(prompt: "hello", model: "composer-2") - let prompt = body["prompt"] as? [String: Any] - #expect(prompt?["text"] as? String == "hello") - let model = body["model"] as? [String: Any] - #expect(model?["id"] as? String == "composer-2") - } - - @Test("Launch body omits the model when none is selected") - func launchBodyWithoutModel() { - let body = CursorProvider.launchBody(prompt: "hi", model: "") - #expect(body["model"] == nil) - #expect((body["prompt"] as? [String: Any])?["text"] as? String == "hi") - } -} diff --git a/TableProTests/Core/AI/CursorProviderStreamParserTests.swift b/TableProTests/Core/AI/CursorProviderStreamParserTests.swift deleted file mode 100644 index d5b4563268..0000000000 --- a/TableProTests/Core/AI/CursorProviderStreamParserTests.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// CursorProviderStreamParserTests.swift -// TableProTests -// - -import Foundation -@testable import TablePro -import Testing - -struct CursorProviderStreamParserTests { - private func deltas(from lines: [String]) -> [CursorProvider.StreamParser.Output] { - var parser = CursorProvider.StreamParser() - return lines.compactMap { parser.consume($0) } - } - - @Test("Assistant deltas are emitted in order, the consolidated result is skipped") - func assistantDeltasEmittedResultSkipped() { - let outputs = deltas(from: [ - "event: assistant", - "data: {\"text\":\"SELECT \"}", - "", - "event: assistant", - "data: {\"text\":\"1\"}", - "", - "event: result", - "data: {\"text\":\"SELECT 1\"}", - "", - "event: done", - "data: {}" - ]) - #expect(outputs == [.text("SELECT "), .text("1"), .done]) - } - - @Test("When no assistant delta arrives, the result text is used as a fallback") - func resultTextUsedWhenNoAssistantDelta() { - let outputs = deltas(from: [ - "event: result", - "data: {\"text\":\"SELECT 1\"}", - "", - "event: done", - "data: {}" - ]) - #expect(outputs == [.text("SELECT 1"), .done]) - } - - @Test("Event and blank lines produce no output") - func eventAndBlankLinesProduceNothing() { - var parser = CursorProvider.StreamParser() - #expect(parser.consume("event: assistant") == nil) - #expect(parser.consume("") == nil) - } - - @Test("Empty assistant text is not emitted") - func emptyAssistantTextSkipped() { - let outputs = deltas(from: [ - "event: assistant", - "data: {\"text\":\"\"}" - ]) - #expect(outputs.isEmpty) - } -} diff --git a/TableProTests/Core/AI/CursorRegistrationTests.swift b/TableProTests/Core/AI/CursorRegistrationTests.swift deleted file mode 100644 index deaeb0f03c..0000000000 --- a/TableProTests/Core/AI/CursorRegistrationTests.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// CursorRegistrationTests.swift -// TableProTests -// - -import Foundation -@testable import TablePro -import Testing - -struct CursorRegistrationTests { - init() { - AIProviderRegistration.registerAll() - } - - private func descriptor() -> AIProviderDescriptor? { - AIProviderRegistry.shared.descriptor(for: AIProviderType.cursor.rawValue) - } - - @Test("Cursor allows an optional API key and is a known type") - func authStyleAndType() { - #expect(AIProviderType.cursor.authStyle == .optionalApiKey) - #expect(AIProviderType.allCases.contains(.cursor)) - #expect(AIProviderType(rawValue: "cursor") == .cursor) - } - - @Test("Cursor descriptor capabilities") - func capabilities() { - let cursor = descriptor() - #expect(cursor != nil) - #expect(cursor?.fetchesModelList == true) - #expect(cursor?.allowsEndpointConfiguration == false) - #expect(cursor?.allowsMaxOutputTokens == false) - #expect(cursor?.oauthFlowKind == nil) - #expect(cursor?.showsTelemetryToggle == false) - } - - @Test("Cursor offers several curated models, not just one") - func curatedModels() { - let ids = descriptor()?.curatedModels.map(\.id) ?? [] - #expect(ids.count > 1) - #expect(ids.contains("composer-2.5")) - #expect(ids.contains("auto")) - } - - @Test("A key selects the REST provider, no key selects the CLI agent provider") - func makeProviderBranchesOnKey() { - let config = AIProviderConfig(type: .cursor, model: "composer-2") - #expect(descriptor()?.makeProvider(config, "sk-cursor-test") is CursorProvider) - #expect(descriptor()?.makeProvider(config, nil) is CursorAgentProvider) - #expect(descriptor()?.makeProvider(config, "") is CursorAgentProvider) - } -} diff --git a/TableProTests/Core/AI/XAIGrokProviderEncodingTests.swift b/TableProTests/Core/AI/XAIGrokProviderEncodingTests.swift deleted file mode 100644 index 38d485b5ba..0000000000 --- a/TableProTests/Core/AI/XAIGrokProviderEncodingTests.swift +++ /dev/null @@ -1,64 +0,0 @@ -// -// XAIGrokProviderEncodingTests.swift -// TableProTests -// - -import Foundation -@testable import TablePro -import Testing - -struct XAIGrokProviderEncodingTests { - @Test("Requests carry the Grok CLI identity headers and the model override") - func requestHeaders() { - let headers = XAIGrokProvider.requestHeaders(accessToken: "tok", model: "grok-build") - #expect(headers["Authorization"] == "Bearer tok") - #expect(headers["X-XAI-Token-Auth"] == "xai-grok-cli") - #expect(headers["x-grok-client-version"] == "0.2.93") - #expect(headers["x-grok-model-override"] == "grok-build") - #expect(headers["Content-Type"] == "application/json") - } - - @Test("An empty model omits the override header") - func emptyModelOmitsOverride() { - let headers = XAIGrokProvider.requestHeaders(accessToken: "tok", model: "") - #expect(headers["x-grok-model-override"] == nil) - } - - @Test("The body sends only the effort that the proxy accepts, without summary or include") - func reasoningBodyShape() throws { - let options = ChatTransportOptions(model: "grok-4.5", reasoningEffort: .high) - let turn = ChatTurnWire(role: .user, blocks: [.text("Hi")]) - let body = try XAIGrokProvider.requestBody(turns: [turn], options: options, stream: true) - - #expect(body["model"] as? String == "grok-4.5") - #expect(body["stream"] as? Bool == true) - let reasoning = body["reasoning"] as? [String: Any] - #expect(reasoning?["effort"] as? String == "high") - #expect(reasoning?["summary"] == nil) - #expect(body["include"] == nil) - } - - @Test("An out-of-range effort is clamped into the accepted low/medium/high set") - func effortClamped() throws { - let options = ChatTransportOptions(model: "grok-4.5", reasoningEffort: .xhigh) - let turn = ChatTurnWire(role: .user, blocks: [.text("Hi")]) - let body = try XAIGrokProvider.requestBody(turns: [turn], options: options, stream: false) - let reasoning = body["reasoning"] as? [String: Any] - #expect(reasoning?["effort"] as? String == "high") - } - - @Test("No reasoning effort means no reasoning key") - func noReasoning() throws { - let options = ChatTransportOptions(model: "grok-4.5") - let turn = ChatTurnWire(role: .user, blocks: [.text("Hi")]) - let body = try XAIGrokProvider.requestBody(turns: [turn], options: options, stream: false) - #expect(body["reasoning"] == nil) - } - - @Test("fetchAvailableModels returns the subscription catalog without a network call") - func subscriptionModels() async throws { - let provider = XAIGrokProvider(model: "grok-build") - let models = try await provider.fetchAvailableModels() - #expect(models.map(\.id) == ["grok-4.5", "grok-build", "grok-composer-2.5-fast"]) - } -} diff --git a/TableProTests/Core/AI/XAIOAuthClientTests.swift b/TableProTests/Core/AI/XAIOAuthClientTests.swift deleted file mode 100644 index 6ede98d792..0000000000 --- a/TableProTests/Core/AI/XAIOAuthClientTests.swift +++ /dev/null @@ -1,66 +0,0 @@ -// -// XAIOAuthClientTests.swift -// TableProTests -// - -import Foundation -@testable import TablePro -import Testing - -struct XAIOAuthClientTests { - private func queryItems(_ url: URL?) -> [String: String] { - guard let url, let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { - return [:] - } - var result: [String: String] = [:] - for item in components.queryItems ?? [] { - result[item.name] = item.value - } - return result - } - - @Test("The authorize URL carries the verified PKCE and client parameters") - func authorizeURLParameters() { - let client = XAIOAuthClient() - let pkce = XAIPKCE() - let redirect = "http://127.0.0.1:56121/callback" - let url = client.authorizeURL(pkce: pkce, redirectURI: redirect) - - #expect(url?.absoluteString.hasPrefix("https://auth.x.ai/oauth2/authorize") == true) - let items = queryItems(url) - #expect(items["response_type"] == "code") - #expect(items["client_id"] == "b1a00492-073a-47ea-816f-4c329264a828") - #expect(items["redirect_uri"] == redirect) - #expect(items["scope"] == "openid profile email offline_access grok-cli:access api:access") - #expect(items["code_challenge"] == pkce.challenge) - #expect(items["code_challenge_method"] == "S256") - #expect(items["state"] == pkce.state) - } - - @Test("A token response parses access, refresh, and id tokens with a server expiry") - func parseTokenResponse() throws { - let json = """ - {"access_token":"a","refresh_token":"r","id_token":"i","expires_in":600,"token_type":"Bearer"} - """ - let response = try XAIOAuthClient.parseTokenResponse(Data(json.utf8), fallbackRefreshToken: "old") - #expect(response.accessToken == "a") - #expect(response.refreshToken == "r") - #expect(response.idToken == "i") - #expect(response.expiresAt > Date()) - } - - @Test("A refresh response without a rotated token keeps the previous refresh token") - func parseTokenResponseKeepsFallbackRefresh() throws { - let json = #"{"access_token":"a2","expires_in":600}"# - let response = try XAIOAuthClient.parseTokenResponse(Data(json.utf8), fallbackRefreshToken: "keep") - #expect(response.refreshToken == "keep") - } - - @Test("A response without an access token is an authentication failure") - func parseTokenResponseMissingAccessToken() { - let json = #"{"refresh_token":"r"}"# - #expect(throws: AIProviderError.self) { - _ = try XAIOAuthClient.parseTokenResponse(Data(json.utf8), fallbackRefreshToken: "") - } - } -} diff --git a/TableProTests/Core/AI/XAIPKCETests.swift b/TableProTests/Core/AI/XAIPKCETests.swift deleted file mode 100644 index 79241d4525..0000000000 --- a/TableProTests/Core/AI/XAIPKCETests.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// XAIPKCETests.swift -// TableProTests -// - -import CryptoKit -import Foundation -@testable import TablePro -import Testing - -struct XAIPKCETests { - @Test("Verifier length is within the RFC 7636 range") - func verifierLength() { - let pkce = XAIPKCE() - #expect(pkce.verifier.count >= 43) - #expect(pkce.verifier.count <= 128) - } - - @Test("Challenge is the base64url SHA-256 of the verifier") - func challengeMatchesVerifier() { - let pkce = XAIPKCE() - let digest = SHA256.hash(data: Data(pkce.verifier.utf8)) - let expected = XAIBase64URL.encode(Data(digest)) - #expect(pkce.challenge == expected) - } - - @Test("Challenge, verifier, and state contain no base64 padding or unsafe characters") - func urlSafeOutput() { - let pkce = XAIPKCE() - for value in [pkce.verifier, pkce.challenge, pkce.state] { - #expect(!value.contains("=")) - #expect(!value.contains("+")) - #expect(!value.contains("/")) - } - } - - @Test("Each instance produces a distinct verifier and state") - func uniquePerInstance() { - let first = XAIPKCE() - let second = XAIPKCE() - #expect(first.verifier != second.verifier) - #expect(first.state != second.state) - } -} diff --git a/TableProTests/Core/AI/XAIRegistrationTests.swift b/TableProTests/Core/AI/XAIRegistrationTests.swift index adddecafbc..e115bbdd86 100644 --- a/TableProTests/Core/AI/XAIRegistrationTests.swift +++ b/TableProTests/Core/AI/XAIRegistrationTests.swift @@ -16,9 +16,9 @@ struct XAIRegistrationTests { AIProviderRegistry.shared.descriptor(for: AIProviderType.xai.rawValue) } - @Test("xAI allows an optional API key and is a known type") + @Test("xAI takes an API key and is a known type") func authStyleAndType() { - #expect(AIProviderType.xai.authStyle == .optionalApiKey) + #expect(AIProviderType.xai.authStyle == .apiKey) #expect(AIProviderType.allCases.contains(.xai)) #expect(AIProviderType(rawValue: "xai") == .xai) #expect(AIProviderType.xai.displayName == "xAI") @@ -58,11 +58,10 @@ struct XAIRegistrationTests { #expect(!fallback.contains(.xhigh)) } - @Test("A key selects the api.x.ai Responses provider, no key selects the Grok subscription provider") - func makeProviderBranchesOnKey() { + @Test("xAI always talks to the api.x.ai Responses endpoint") + func makeProviderIsResponses() { let config = AIProviderConfig(type: .xai, model: "grok-4.5") #expect(descriptor()?.makeProvider(config, "xai-test-key") is OpenAIResponsesProvider) - #expect(descriptor()?.makeProvider(config, nil) is XAIGrokProvider) - #expect(descriptor()?.makeProvider(config, "") is XAIGrokProvider) + #expect(descriptor()?.makeProvider(config, nil) is OpenAIResponsesProvider) } } diff --git a/TableProTests/Core/AI/XAITokenStoreTests.swift b/TableProTests/Core/AI/XAITokenStoreTests.swift deleted file mode 100644 index 54f6766aac..0000000000 --- a/TableProTests/Core/AI/XAITokenStoreTests.swift +++ /dev/null @@ -1,120 +0,0 @@ -// -// XAITokenStoreTests.swift -// TableProTests -// - -import Foundation -@testable import TablePro -import Testing - -private actor FakeXAIRefresher: XAITokenRefreshing { - private(set) var callCount = 0 - private let response: XAITokenResponse - private let delayNanos: UInt64 - - init(response: XAITokenResponse, delayNanos: UInt64 = 0) { - self.response = response - self.delayNanos = delayNanos - } - - func refresh(refreshToken: String) async throws -> XAITokenResponse { - callCount += 1 - if delayNanos > 0 { - try? await Task.sleep(nanoseconds: delayNanos) - } - return response - } -} - -struct XAITokenStoreTests { - private func tokens(refresh: String = "r", expiresIn: TimeInterval) -> XAITokens { - XAITokens( - accessToken: "access", - refreshToken: refresh, - idToken: "", - email: "dev@example.com", - expiresAt: Date().addingTimeInterval(expiresIn) - ) - } - - private func response(access: String, refresh: String, expiresIn: TimeInterval) -> XAITokenResponse { - XAITokenResponse( - accessToken: access, - refreshToken: refresh, - idToken: "", - expiresAt: Date().addingTimeInterval(expiresIn) - ) - } - - @Test("Saved tokens round-trip through Keychain storage") - func saveAndLoadRoundTrip() async { - let store = XAITokenStore( - keychain: InMemoryKeychain(), - refresher: FakeXAIRefresher(response: response(access: "a", refresh: "r", expiresIn: 600)) - ) - await store.save(tokens(expiresIn: 600)) - let loaded = await store.currentTokens() - #expect(loaded?.accessToken == "access") - #expect(loaded?.email == "dev@example.com") - #expect(await store.isSignedIn()) - } - - @Test("A valid token is returned without refreshing") - func notExpiredSkipsRefresh() async throws { - let refresher = FakeXAIRefresher(response: response(access: "new", refresh: "r2", expiresIn: 600)) - let store = XAITokenStore(keychain: InMemoryKeychain(), refresher: refresher) - await store.save(tokens(expiresIn: 600)) - let token = try await store.validAccessToken() - #expect(token == "access") - #expect(await refresher.callCount == 0) - } - - @Test("A token inside the 60s skew window triggers a refresh") - func expirySkewTriggersRefresh() async throws { - let refresher = FakeXAIRefresher(response: response(access: "fresh", refresh: "r2", expiresIn: 600)) - let store = XAITokenStore(keychain: InMemoryKeychain(), refresher: refresher) - await store.save(tokens(expiresIn: 30)) - let token = try await store.validAccessToken() - #expect(token == "fresh") - #expect(await refresher.callCount == 1) - } - - @Test("A rotated refresh token is persisted") - func refreshTokenRotationPersisted() async throws { - let refresher = FakeXAIRefresher(response: response(access: "fresh", refresh: "rotated", expiresIn: 600)) - let store = XAITokenStore(keychain: InMemoryKeychain(), refresher: refresher) - await store.save(tokens(refresh: "old", expiresIn: -10)) - _ = try await store.validAccessToken() - let current = await store.currentTokens() - #expect(current?.refreshToken == "rotated") - } - - @Test("Concurrent callers share a single refresh") - func singleFlightRefresh() async throws { - let refresher = FakeXAIRefresher( - response: response(access: "fresh", refresh: "r2", expiresIn: 600), - delayNanos: 80_000_000 - ) - let store = XAITokenStore(keychain: InMemoryKeychain(), refresher: refresher) - await store.save(tokens(expiresIn: -10)) - try await withThrowingTaskGroup(of: String.self) { group in - for _ in 0..<8 { - group.addTask { try await store.validAccessToken() } - } - for try await _ in group {} - } - #expect(await refresher.callCount == 1) - } - - @Test("Clearing removes the stored session") - func clearRemovesTokens() async { - let store = XAITokenStore( - keychain: InMemoryKeychain(), - refresher: FakeXAIRefresher(response: response(access: "a", refresh: "r", expiresIn: 600)) - ) - await store.save(tokens(expiresIn: 600)) - await store.clear() - #expect(await store.currentTokens() == nil) - #expect(await store.isSignedIn() == false) - } -} diff --git a/TableProTests/Models/Settings/SettingsFieldDecodingTests.swift b/TableProTests/Models/Settings/SettingsFieldDecodingTests.swift index 92bddfd82d..6ead19dab2 100644 --- a/TableProTests/Models/Settings/SettingsFieldDecodingTests.swift +++ b/TableProTests/Models/Settings/SettingsFieldDecodingTests.swift @@ -133,6 +133,25 @@ struct AISettingsProviderDecodingTests { #expect(settings.maxSchemaTables == 42) } + @Test("A stored Cursor provider row is dropped and the rest of AI settings load") + func removedCursorRowIsDropped() throws { + let json = """ + { + "inlineSuggestionsEnabled": true, + "activeProviderID": "\(otherID.uuidString)", + "providers": [ + {"id": "\(otherID.uuidString)", "type": "cursor", "name": "Cursor"}, + {"id": "\(keptID.uuidString)", "type": "xai"} + ] + } + """ + let settings = try decode(json) + #expect(settings.providers.map(\.id) == [keptID]) + #expect(settings.providers.first?.type == .xai) + #expect(settings.activeProvider == nil) + #expect(settings.inlineSuggestionsEnabled == true) + } + @Test("A provider row without an id is dropped") func rowWithoutIDIsDropped() throws { let json = """