Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions TablePro/Core/AI/Chat/Reasoning.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions TablePro/Core/AI/OAuthProviderService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ enum OAuthProviderRegistry {
return CopilotService.shared
case .chatgptCodex:
return ChatGPTCodexService.shared
case .xai:
return XAIService.shared
default:
return nil
}
Expand Down Expand Up @@ -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)
}
}
}
28 changes: 24 additions & 4 deletions TablePro/Core/AI/OpenAIResponsesProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,41 @@
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")

private let endpoint: String
private let apiKey: String?
private let model: String
private let maxOutputTokens: Int?
private let dialect: ResponsesDialect
private let session: URLSession

init(
endpoint: String,
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
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)]
Expand Down
24 changes: 24 additions & 0 deletions TablePro/Core/AI/Registry/AIProviderRegistration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
82 changes: 82 additions & 0 deletions TablePro/Core/AI/XAI/XAI.swift
Original file line number Diff line number Diff line change
@@ -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 ?? ""
}
}
Loading
Loading