From 2bb8762fcd5a03f48e1e4d433730392536125fdf Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 13 Jul 2026 20:15:11 +0700 Subject: [PATCH] feat(ai-providers): add xAI as an AI provider --- CHANGELOG.md | 1 + TablePro/Core/AI/Chat/Reasoning.swift | 8 + TablePro/Core/AI/OAuthProviderService.swift | 15 ++ .../Core/AI/OpenAIResponsesProvider.swift | 28 ++- .../AI/Registry/AIProviderRegistration.swift | 24 ++ TablePro/Core/AI/XAI/XAI.swift | 82 +++++++ TablePro/Core/AI/XAI/XAICallbackServer.swift | 230 ++++++++++++++++++ TablePro/Core/AI/XAI/XAIGrokProvider.swift | 120 +++++++++ TablePro/Core/AI/XAI/XAIModels.swift | 31 +++ TablePro/Core/AI/XAI/XAIOAuthClient.swift | 123 ++++++++++ TablePro/Core/AI/XAI/XAIPKCE.swift | 33 +++ TablePro/Core/AI/XAI/XAIService.swift | 101 ++++++++ TablePro/Core/AI/XAI/XAITokenStore.swift | 127 ++++++++++ TablePro/Models/AI/AIModels.swift | 5 + .../Settings/AIProviderDetailSheet.swift | 106 ++++++++ TablePro/Views/Settings/AISettingsView.swift | 15 ++ .../Core/AI/AIProviderCapabilitiesTests.swift | 11 +- .../OpenAIResponsesProviderParserTests.swift | 16 ++ .../Core/AI/ResponsesDialectTests.swift | 26 ++ .../AI/XAIGrokProviderEncodingTests.swift | 65 +++++ .../Core/AI/XAIOAuthClientTests.swift | 67 +++++ TableProTests/Core/AI/XAIPKCETests.swift | 45 ++++ .../Core/AI/XAIRegistrationTests.swift | 69 ++++++ .../Core/AI/XAITokenStoreTests.swift | 121 +++++++++ docs/customization/settings.mdx | 2 +- docs/features/ai-assistant.mdx | 15 +- 26 files changed, 1476 insertions(+), 10 deletions(-) create mode 100644 TablePro/Core/AI/XAI/XAI.swift create mode 100644 TablePro/Core/AI/XAI/XAICallbackServer.swift create mode 100644 TablePro/Core/AI/XAI/XAIGrokProvider.swift create mode 100644 TablePro/Core/AI/XAI/XAIModels.swift create mode 100644 TablePro/Core/AI/XAI/XAIOAuthClient.swift create mode 100644 TablePro/Core/AI/XAI/XAIPKCE.swift create mode 100644 TablePro/Core/AI/XAI/XAIService.swift create mode 100644 TablePro/Core/AI/XAI/XAITokenStore.swift create mode 100644 TableProTests/Core/AI/ResponsesDialectTests.swift create mode 100644 TableProTests/Core/AI/XAIGrokProviderEncodingTests.swift create mode 100644 TableProTests/Core/AI/XAIOAuthClientTests.swift create mode 100644 TableProTests/Core/AI/XAIPKCETests.swift create mode 100644 TableProTests/Core/AI/XAIRegistrationTests.swift create mode 100644 TableProTests/Core/AI/XAITokenStoreTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b7b6730d..e48918f9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- xAI (Grok) as an AI provider. Paste a key from the xAI Console to use Grok 4.5 or Grok 4.3 on API credits, or click **Sign in with xAI** to use a SuperGrok or X Premium+ subscription with no key. Supports reasoning effort and image attachments. - Connect to Google Cloud SQL through the Cloud SQL Auth Proxy without starting it yourself. Enable it on a MySQL, PostgreSQL, or SQL Server connection, set the instance connection name, and TablePro runs and stops the proxy with the connection. Supports Application Default Credentials, a service account key, and IAM database authentication, and can download the proxy or use one already on your Mac. (#1728) - Beancount ledger support as a downloadable, read-only file-based driver. Transactions, postings (with resolved cost basis), accounts, prices, computed balances, and balance assertions project to SQL tables through user-provided `rledger` or Python Beancount, and BQL runs with a `BQL:` prefix when `rledger` is available. (#1474) - The Favorites sidebar **+** menu now includes **New Query**, which opens an empty SQL query tab. diff --git a/TablePro/Core/AI/Chat/Reasoning.swift b/TablePro/Core/AI/Chat/Reasoning.swift index 17907dc17..cde277475 100644 --- a/TablePro/Core/AI/Chat/Reasoning.swift +++ b/TablePro/Core/AI/Chat/Reasoning.swift @@ -26,6 +26,14 @@ enum ReasoningEffort: String, Codable, Sendable, CaseIterable, Identifiable { var openAIWireValue: String { rawValue } + var xaiReasoningEffort: String { + switch self { + case .minimal, .low: return "low" + case .medium: return "medium" + case .high, .xhigh: return "high" + } + } + var anthropicAdaptiveEffort: String? { switch self { case .minimal: return nil diff --git a/TablePro/Core/AI/OAuthProviderService.swift b/TablePro/Core/AI/OAuthProviderService.swift index 85e26e367..1886b5eb3 100644 --- a/TablePro/Core/AI/OAuthProviderService.swift +++ b/TablePro/Core/AI/OAuthProviderService.swift @@ -34,6 +34,8 @@ enum OAuthProviderRegistry { return CopilotService.shared case .chatgptCodex: return ChatGPTCodexService.shared + case .xai: + return XAIService.shared default: return nil } @@ -65,3 +67,16 @@ 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/OpenAIResponsesProvider.swift b/TablePro/Core/AI/OpenAIResponsesProvider.swift index f1f6cde3f..2197e5206 100644 --- a/TablePro/Core/AI/OpenAIResponsesProvider.swift +++ b/TablePro/Core/AI/OpenAIResponsesProvider.swift @@ -6,6 +6,18 @@ import Foundation import os +enum ResponsesDialect: Sendable { + case openAI + case xai + + var defaultTestModel: String { + switch self { + case .openAI: return "gpt-5.5" + case .xai: return "grok-4.5" + } + } +} + final class OpenAIResponsesProvider: ChatTransport { private static let logger = Logger(subsystem: "com.TablePro", category: "OpenAIResponsesProvider") @@ -13,6 +25,7 @@ final class OpenAIResponsesProvider: ChatTransport { private let apiKey: String? private let model: String private let maxOutputTokens: Int? + private let dialect: ResponsesDialect private let session: URLSession init( @@ -20,12 +33,14 @@ final class OpenAIResponsesProvider: ChatTransport { apiKey: String?, model: String = "", maxOutputTokens: Int? = nil, + dialect: ResponsesDialect = .openAI, session: URLSession = URLSession(configuration: .ephemeral) ) { self.endpoint = endpoint.normalizedEndpoint() self.apiKey = apiKey?.trimmingCharacters(in: .whitespacesAndNewlines) self.model = model.trimmingCharacters(in: .whitespacesAndNewlines) self.maxOutputTokens = maxOutputTokens + self.dialect = dialect self.session = session } @@ -67,7 +82,7 @@ final class OpenAIResponsesProvider: ChatTransport { } func testConnection() async throws -> Bool { - let testModel = model.isEmpty ? "gpt-5.5" : model + let testModel = model.isEmpty ? dialect.defaultTestModel : model let testOptions = ChatTransportOptions(model: testModel, maxOutputTokens: 16) let testTurn = ChatTurnWire(role: .user, blocks: [.text("Hi")]) let request = try buildRequest(turns: [testTurn], options: testOptions, stream: false) @@ -117,8 +132,13 @@ final class OpenAIResponsesProvider: ChatTransport { } if let effort = options.reasoningEffort { - body["reasoning"] = ["effort": effort.openAIWireValue, "summary": "auto"] - body["include"] = ["reasoning.encrypted_content"] + switch dialect { + case .openAI: + body["reasoning"] = ["effort": effort.openAIWireValue, "summary": "auto"] + body["include"] = ["reasoning.encrypted_content"] + case .xai: + body["reasoning"] = ["effort": effort.xaiReasoningEffort] + } } if !options.tools.isEmpty { @@ -275,7 +295,7 @@ final class OpenAIResponsesProvider: ChatTransport { return [.textDelta(delta)] } return [] - case "response.reasoning_summary_text.delta": + case "response.reasoning_summary_text.delta", "response.reasoning_text.delta": guard let itemID = json["item_id"] as? String, let delta = json["delta"] as? String, !delta.isEmpty else { return [] } return [.reasoningDelta(id: itemID, text: delta)] diff --git a/TablePro/Core/AI/Registry/AIProviderRegistration.swift b/TablePro/Core/AI/Registry/AIProviderRegistration.swift index 9a72b3329..bd37488ec 100644 --- a/TablePro/Core/AI/Registry/AIProviderRegistration.swift +++ b/TablePro/Core/AI/Registry/AIProviderRegistration.swift @@ -61,6 +61,30 @@ enum AIProviderRegistration { } )) + registry.register(AIProviderDescriptor( + typeID: AIProviderType.xai.rawValue, + displayName: AIProviderType.xai.displayName, + defaultEndpoint: AIProviderType.xai.defaultEndpoint, + capabilities: [ + .chat, .models, .reasoning, .images, + .endpointConfigurable, .maxOutputTokens, .modelListFetchable + ], + symbolName: iconForType(.xai), + curatedModels: XAI.apiCuratedModels, + 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) + } + )) + for type in [AIProviderType.openRouter, .openCode, .ollama, .custom] { var capabilities: AIProviderCapabilities = [ .chat, .models, .endpointConfigurable, .maxOutputTokens, .modelListFetchable diff --git a/TablePro/Core/AI/XAI/XAI.swift b/TablePro/Core/AI/XAI/XAI.swift new file mode 100644 index 000000000..c534ae79e --- /dev/null +++ b/TablePro/Core/AI/XAI/XAI.swift @@ -0,0 +1,82 @@ +// +// 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 new file mode 100644 index 000000000..6f3ba3de0 --- /dev/null +++ b/TablePro/Core/AI/XAI/XAICallbackServer.swift @@ -0,0 +1,230 @@ +// +// 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.localizedDescription, 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 new file mode 100644 index 000000000..6cfb4721e --- /dev/null +++ b/TablePro/Core/AI/XAI/XAIGrokProvider.swift @@ -0,0 +1,120 @@ +// +// 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 -> [String] { + XAI.subscriptionModelIDs + } + + 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 new file mode 100644 index 000000000..8e84cbe5e --- /dev/null +++ b/TablePro/Core/AI/XAI/XAIModels.swift @@ -0,0 +1,31 @@ +// +// 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 new file mode 100644 index 000000000..54fc75eef --- /dev/null +++ b/TablePro/Core/AI/XAI/XAIOAuthClient.swift @@ -0,0 +1,123 @@ +// +// 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.localizedDescription, 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 new file mode 100644 index 000000000..527a27e88 --- /dev/null +++ b/TablePro/Core/AI/XAI/XAIPKCE.swift @@ -0,0 +1,33 @@ +// +// 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 new file mode 100644 index 000000000..c18a31f24 --- /dev/null +++ b/TablePro/Core/AI/XAI/XAIService.swift @@ -0,0 +1,101 @@ +// +// XAIService.swift +// TablePro +// + +import AppKit +import Foundation +import os + +@MainActor @Observable +final class XAIService { + static let shared = XAIService() + + 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 + } + } + + private(set) var authState: AuthState = .signedOut + private(set) var errorMessage: String? + + @ObservationIgnored private let tokenStore: XAITokenStore + @ObservationIgnored 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.localizedDescription, privacy: .public)") + errorMessage = error.localizedDescription + authState = .signedOut + } +} diff --git a/TablePro/Core/AI/XAI/XAITokenStore.swift b/TablePro/Core/AI/XAI/XAITokenStore.swift new file mode 100644 index 000000000..0ae5bc1da --- /dev/null +++ b/TablePro/Core/AI/XAI/XAITokenStore.swift @@ -0,0 +1,127 @@ +// +// 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 = KeychainHelper.shared, + 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 a04758aa2..027d57998 100644 --- a/TablePro/Models/AI/AIModels.swift +++ b/TablePro/Models/AI/AIModels.swift @@ -15,6 +15,7 @@ enum AIProviderType: String, Codable, CaseIterable, Identifiable, Sendable { case openAI case openRouter case gemini + case xai case ollama case openCode case custom @@ -30,6 +31,7 @@ enum AIProviderType: String, Codable, CaseIterable, Identifiable, Sendable { case .openAI: return "OpenAI" case .openRouter: return "OpenRouter" case .gemini: return "Gemini" + case .xai: return "xAI" case .ollama: return "Ollama" case .openCode: return "OpenCode Zen" case .custom: return String(localized: "Custom") @@ -45,6 +47,7 @@ enum AIProviderType: String, Codable, CaseIterable, Identifiable, Sendable { case .openAI: return "https://api.openai.com" case .openRouter: return "https://openrouter.ai/api" case .gemini: return "https://generativelanguage.googleapis.com" + case .xai: return "https://api.x.ai" case .ollama: return "http://localhost:11434" case .openCode: return "https://opencode.ai/zen" case .custom: return "" @@ -62,6 +65,7 @@ enum AIProviderType: String, Codable, CaseIterable, Identifiable, Sendable { case .copilot: return .oauth case .chatgptCodex: return .oauth case .cursor: return .optionalApiKey + case .xai: return .optionalApiKey case .ollama: return .none case .openCode: return .optionalApiKey default: return .apiKey @@ -77,6 +81,7 @@ enum AIProviderType: String, Codable, CaseIterable, Identifiable, Sendable { case .openAI: return "cpu" case .openRouter: return "globe" case .gemini: return "wand.and.stars" + case .xai: return "x.circle" case .ollama: return "desktopcomputer" case .openCode: return "sparkles" case .custom: return "server.rack" diff --git a/TablePro/Views/Settings/AIProviderDetailSheet.swift b/TablePro/Views/Settings/AIProviderDetailSheet.swift index 3cd97495f..331442e17 100644 --- a/TablePro/Views/Settings/AIProviderDetailSheet.swift +++ b/TablePro/Views/Settings/AIProviderDetailSheet.swift @@ -32,6 +32,8 @@ struct AIProviderDetailSheet: View { @State private var cursorAgentService = CursorAgentService.shared + @State private var xaiService = XAIService.shared + @State private var showRemoveConfirmation = false enum TestResult: Equatable { @@ -98,6 +100,9 @@ struct AIProviderDetailSheet: View { if draft.type == .cursor { Task { await cursorAgentService.refreshStatus() } } + if draft.type == .xai { + Task { await xaiService.refreshAuthState() } + } fetchModels() } } @@ -150,6 +155,8 @@ struct AIProviderDetailSheet: View { case .apiKey, .optionalApiKey: if draft.type == .cursor { cursorAuthSection + } else if draft.type == .xai { + xaiAuthSection } else { apiKeyAuthSection } @@ -333,6 +340,105 @@ struct AIProviderDetailSheet: View { } } + @ViewBuilder + private var xaiAuthSection: some View { + xaiAPIKeySection + xaiSignInSection + } + + private var xaiAPIKeySection: some View { + Section { + SecureField(String(localized: "API Key"), text: $apiKey) + .onChange(of: apiKey) { 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) diff --git a/TablePro/Views/Settings/AISettingsView.swift b/TablePro/Views/Settings/AISettingsView.swift index 19c85c7d0..fc13b1a72 100644 --- a/TablePro/Views/Settings/AISettingsView.swift +++ b/TablePro/Views/Settings/AISettingsView.swift @@ -16,6 +16,7 @@ struct AISettingsView: View { @State private var pendingDeleteID: UUID? @State private var chatGPTCodexService = ChatGPTCodexService.shared @State private var cursorAgentService = CursorAgentService.shared + @State private var xaiService = XAIService.shared @State private var providersWithKey: Set = [] var body: some View { @@ -34,6 +35,7 @@ struct AISettingsView: View { .task { refreshKeyAvailability() } .task { await chatGPTCodexService.refreshAuthState() } .task { await cursorAgentService.refreshStatus() } + .task { await xaiService.refreshAuthState() } .onChange(of: settings.providers.map(\.id)) { refreshKeyAvailability() } @@ -324,6 +326,9 @@ struct AISettingsView: View { if provider.type == .cursor { return cursorStatusText(for: provider) } + if provider.type == .xai { + return xaiStatusText(for: provider) + } return providersWithKey.contains(provider.id) ? String(localized: "API key set") : String(localized: "Not configured") @@ -364,6 +369,16 @@ struct AISettingsView: View { 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") + } + private func customStatusText(for provider: AIProviderConfig) -> String { if providersWithKey.contains(provider.id) { return String(localized: "API key set") diff --git a/TableProTests/Core/AI/AIProviderCapabilitiesTests.swift b/TableProTests/Core/AI/AIProviderCapabilitiesTests.swift index a80c5820d..715fd7a39 100644 --- a/TableProTests/Core/AI/AIProviderCapabilitiesTests.swift +++ b/TableProTests/Core/AI/AIProviderCapabilitiesTests.swift @@ -40,7 +40,7 @@ struct AIProviderCapabilitiesTests { @Test("HTTP API-key providers accept max output tokens, a configurable endpoint, and model fetch") func standardHTTPProviders() { - for type in [AIProviderType.openAI, .claude, .gemini, .openRouter, .ollama] { + for type in [AIProviderType.openAI, .claude, .gemini, .xai, .openRouter, .ollama] { let provider = descriptor(type) #expect(provider?.allowsMaxOutputTokens == true, "\(type.rawValue) should accept max output tokens") #expect(provider?.allowsEndpointConfiguration == true, "\(type.rawValue) should allow endpoint config") @@ -48,6 +48,13 @@ struct AIProviderCapabilitiesTests { } } + @Test("Every provider type has a registered descriptor") + func everyTypeHasDescriptor() { + for type in AIProviderType.allCases { + #expect(descriptor(type) != nil, "\(type.rawValue) must have a registered descriptor") + } + } + @Test("Only the custom provider allows the name field") func nameFieldOnlyForCustom() { #expect(descriptor(.custom)?.allowsNameConfiguration == true) @@ -57,7 +64,7 @@ struct AIProviderCapabilitiesTests { @Test("Telemetry toggle is exclusive to Copilot") func telemetryToggleOnlyForCopilot() { - for type in [AIProviderType.openAI, .claude, .gemini, .chatgptCodex, .custom, .ollama] { + for type in [AIProviderType.openAI, .claude, .gemini, .xai, .chatgptCodex, .custom, .ollama] { #expect(descriptor(type)?.showsTelemetryToggle == false, "\(type.rawValue) must not show telemetry") } } diff --git a/TableProTests/Core/AI/OpenAIResponsesProviderParserTests.swift b/TableProTests/Core/AI/OpenAIResponsesProviderParserTests.swift index 1de559a30..a0e2c5e72 100644 --- a/TableProTests/Core/AI/OpenAIResponsesProviderParserTests.swift +++ b/TableProTests/Core/AI/OpenAIResponsesProviderParserTests.swift @@ -58,6 +58,22 @@ struct OpenAIResponsesProviderParserTests { #expect(text == "I should look at") } + @Test("reasoning_text.delta yields reasoningDelta for xAI raw reasoning") + func reasoningTextDelta() throws { + var state = ResponsesStreamState() + let events = try parse([ + "type": "response.reasoning_text.delta", + "item_id": "rs_grok", + "delta": "Let me check the schema" + ], state: &state) + guard case .reasoningDelta(let id, let text) = events.first else { + Issue.record("expected reasoningDelta; got \(events)") + return + } + #expect(id == "rs_grok") + #expect(text == "Let me check the schema") + } + @Test("output_item.done reasoning yields reasoningEnd with encrypted opaque and real itemID") func reasoningEndCarriesEncryptedOpaque() throws { var state = ResponsesStreamState() diff --git a/TableProTests/Core/AI/ResponsesDialectTests.swift b/TableProTests/Core/AI/ResponsesDialectTests.swift new file mode 100644 index 000000000..2a95241d3 --- /dev/null +++ b/TableProTests/Core/AI/ResponsesDialectTests.swift @@ -0,0 +1,26 @@ +// +// ResponsesDialectTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("ResponsesDialect") +struct ResponsesDialectTests { + @Test("Each dialect has its own default test model") + func defaultTestModels() { + #expect(ResponsesDialect.openAI.defaultTestModel == "gpt-5.5") + #expect(ResponsesDialect.xai.defaultTestModel == "grok-4.5") + } + + @Test("xAI reasoning effort clamps to the low/medium/high set the API accepts") + func effortMapping() { + #expect(ReasoningEffort.minimal.xaiReasoningEffort == "low") + #expect(ReasoningEffort.low.xaiReasoningEffort == "low") + #expect(ReasoningEffort.medium.xaiReasoningEffort == "medium") + #expect(ReasoningEffort.high.xaiReasoningEffort == "high") + #expect(ReasoningEffort.xhigh.xaiReasoningEffort == "high") + } +} diff --git a/TableProTests/Core/AI/XAIGrokProviderEncodingTests.swift b/TableProTests/Core/AI/XAIGrokProviderEncodingTests.swift new file mode 100644 index 000000000..71745edc3 --- /dev/null +++ b/TableProTests/Core/AI/XAIGrokProviderEncodingTests.swift @@ -0,0 +1,65 @@ +// +// XAIGrokProviderEncodingTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("XAIGrokProvider request encoding") +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 == ["grok-4.5", "grok-build", "grok-composer-2.5-fast"]) + } +} diff --git a/TableProTests/Core/AI/XAIOAuthClientTests.swift b/TableProTests/Core/AI/XAIOAuthClientTests.swift new file mode 100644 index 000000000..a1521632f --- /dev/null +++ b/TableProTests/Core/AI/XAIOAuthClientTests.swift @@ -0,0 +1,67 @@ +// +// XAIOAuthClientTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("XAIOAuthClient") +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 new file mode 100644 index 000000000..0873a7329 --- /dev/null +++ b/TableProTests/Core/AI/XAIPKCETests.swift @@ -0,0 +1,45 @@ +// +// XAIPKCETests.swift +// TableProTests +// + +import CryptoKit +import Foundation +@testable import TablePro +import Testing + +@Suite("XAIPKCE") +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 new file mode 100644 index 000000000..25439b7aa --- /dev/null +++ b/TableProTests/Core/AI/XAIRegistrationTests.swift @@ -0,0 +1,69 @@ +// +// XAIRegistrationTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("xAI provider registration") +struct XAIRegistrationTests { + init() { + AIProviderRegistration.registerAll() + } + + private func descriptor() -> AIProviderDescriptor? { + AIProviderRegistry.shared.descriptor(for: AIProviderType.xai.rawValue) + } + + @Test("xAI allows an optional API key and is a known type") + func authStyleAndType() { + #expect(AIProviderType.xai.authStyle == .optionalApiKey) + #expect(AIProviderType.allCases.contains(.xai)) + #expect(AIProviderType(rawValue: "xai") == .xai) + #expect(AIProviderType.xai.displayName == "xAI") + #expect(AIProviderType.xai.defaultEndpoint == "https://api.x.ai") + } + + @Test("xAI descriptor capabilities") + func capabilities() { + let xai = descriptor() + #expect(xai != nil) + #expect(xai?.supportsReasoning == true) + #expect(xai?.supportsImages == true) + #expect(xai?.allowsEndpointConfiguration == true) + #expect(xai?.allowsMaxOutputTokens == true) + #expect(xai?.fetchesModelList == true) + #expect(xai?.allowsNameConfiguration == false) + #expect(xai?.showsTelemetryToggle == false) + } + + @Test("xAI curates the current Grok chat models and no retired slugs") + func curatedModels() { + let ids = descriptor()?.curatedModels.map(\.id) ?? [] + #expect(ids == ["grok-4.5", "grok-4.3"]) + for retired in ["grok-4", "grok-4-0709", "grok-3", "grok-3-mini", "grok-code-fast-1"] { + #expect(!ids.contains(retired), "retired slug \(retired) redirects and mis-bills; must not be curated") + } + } + + @Test("xAI never offers an effort level the API rejects") + func effortLevels() { + let levels = descriptor()?.supportedEffortLevels(forModelID: "grok-4.5") ?? [] + #expect(levels == [.low, .medium, .high]) + #expect(!levels.contains(.minimal)) + #expect(!levels.contains(.xhigh)) + let fallback = descriptor()?.supportedEffortLevels(forModelID: "grok-unknown") ?? [] + #expect(!fallback.contains(.minimal)) + #expect(!fallback.contains(.xhigh)) + } + + @Test("A key selects the api.x.ai Responses provider, no key selects the Grok subscription provider") + func makeProviderBranchesOnKey() { + 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) + } +} diff --git a/TableProTests/Core/AI/XAITokenStoreTests.swift b/TableProTests/Core/AI/XAITokenStoreTests.swift new file mode 100644 index 000000000..8321b56a9 --- /dev/null +++ b/TableProTests/Core/AI/XAITokenStoreTests.swift @@ -0,0 +1,121 @@ +// +// 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 + } +} + +@Suite("XAITokenStore") +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/docs/customization/settings.mdx b/docs/customization/settings.mdx index 299b9fc06..fb2d3638d 100644 --- a/docs/customization/settings.mdx +++ b/docs/customization/settings.mdx @@ -112,7 +112,7 @@ The provider used by default. Override it per turn from the model picker in the ### Providers -List of configured providers. Click a row to open its detail sheet, or pick **Add Provider…** to add one. The choices are GitHub Copilot (OAuth device flow), ChatGPT (sign in with your subscription), Claude, OpenAI, OpenRouter, Gemini, Ollama (no auth, local), and a custom OpenAI-compatible endpoint. +List of configured providers. Click a row to open its detail sheet, or pick **Add Provider…** to add one. The choices are GitHub Copilot (OAuth device flow), ChatGPT (sign in with your subscription), Cursor, Claude, OpenAI, OpenRouter, OpenCode Zen, Gemini, xAI (API key or sign in with a subscription), Ollama (no auth, local), and a custom OpenAI-compatible endpoint. API keys and ChatGPT tokens are stored in the macOS Keychain. Removing a provider deletes its key. diff --git a/docs/features/ai-assistant.mdx b/docs/features/ai-assistant.mdx index a1c09da8a..a83871b74 100644 --- a/docs/features/ai-assistant.mdx +++ b/docs/features/ai-assistant.mdx @@ -1,6 +1,6 @@ --- title: AI Assistant -description: "Built-in AI for SQL: chat with tool calling, inline suggestions, explain, optimize, fix-error. 10 providers." +description: "Built-in AI for SQL: chat with tool calling, inline suggestions, explain, optimize, fix-error. 11 providers." --- # AI Assistant @@ -28,7 +28,7 @@ Open **Settings** (`Cmd+,`) > **AI**. The tab is modeled on Xcode's Intelligence ### Add a Provider -1. Click **Add Provider...** and pick a type: GitHub Copilot, ChatGPT, Cursor, Claude, OpenAI, OpenRouter, OpenCode Zen, Gemini, Ollama, or a custom OpenAI-compatible endpoint. +1. Click **Add Provider...** and pick a type: GitHub Copilot, ChatGPT, Cursor, Claude, OpenAI, OpenRouter, OpenCode Zen, Gemini, xAI, Ollama, or a custom OpenAI-compatible endpoint. 2. Enter the API key, or sign in for Copilot and ChatGPT. 3. Enter a model name, or pick one from the fetched list. Click **Reload** if needed. 4. Click **Test Connection**. @@ -62,6 +62,15 @@ Pick a model such as Composer 2, or one of the frontier models Cursor proxies. Cursor runs as an agent rather than a chat-completions endpoint. Two limits follow: the AI cannot call TablePro's database tools (chat answers from the schema context TablePro sends, but Edit and Agent modes do not run queries through Cursor), and responses can be slower than the other providers. Using the API or CLI in your own app is permitted by Cursor's terms. +### xAI + +Use Grok two ways: + +- **API key:** paste a key from the xAI Console. Stored in the macOS Keychain. Calls `api.x.ai` and bills xAI API credits. Pick Grok 4.5 (500k context) or Grok 4.3 (1M context, cheaper). Grok 4.5 always reasons; the effort level sets depth, not on or off. +- **Sign in with xAI:** leave the key blank and click **Sign in with xAI** to use a SuperGrok or X Premium+ subscription with no key. The browser shows a Grok Build consent screen, since sign-in reuses xAI's Grok Build client. This path is unofficial and may change. + +Grok 4.3 is the cheaper pick for inline suggestions. + ## Chat Press `Cmd+Shift+L`, click the inspector toggle and pick **AI Chat**, or use **View** > **Toggle AI Chat**. The right inspector has a Details / AI Chat segmented picker at the top. @@ -194,7 +203,7 @@ The cap is 10 tool round trips per turn. If you hit it, send a follow-up to cont | `execute_query` | Run `SELECT` / `INSERT` / `UPDATE` / `DELETE`. Multi-statement input rejected. Destructive DDL blocked. | Edit, Agent | | `confirm_destructive_operation` | Run destructive DDL after the model passes the verbatim phrase `I understand this is irreversible` | Agent | -Provider support: Claude, OpenAI, OpenRouter, OpenCode Zen, Gemini, Ollama (model-dependent), GitHub Copilot, and custom OpenAI-compatible endpoints. +Provider support: Claude, OpenAI, OpenRouter, OpenCode Zen, Gemini, xAI, Ollama (model-dependent), GitHub Copilot, and custom OpenAI-compatible endpoints. ### Attach Context with `@`