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

### Added

- AI Chat: OpenAI provider now uses the Responses API for GPT-5 and Codex models, with reasoning shown in a collapsible Thinking panel above each reply. (#1112)
- AI Chat: image input via drag-and-drop or paste into the composer. HEIC, TIFF, and BMP convert to PNG or JPEG. EXIF and GPS metadata are stripped before sending. (#1112)
- AI Chat: reasoning effort picker for OpenAI (Minimal to Extra High) and Claude (Low to Extra High), shown only for models that support it. (#1112)
- AI Chat: curated model picker for the OpenAI provider with GPT-5.5, GPT-5-Codex, GPT-5.3-Codex, and GPT-5.4-Mini at the top; free-text entry still works for unlisted models. (#1112)
- AI Chat: Claude provider now supports extended thinking on Opus 4.7, Sonnet 4.6, and Haiku 4.5 using the same Thinking panel. (#1112)
- AI Chat: tool schemas are strict by default, matching the Responses API and Claude strict tool use. (#1112)
- iOS: SQL Server (MSSQL) connections via FreeTDS over TDS 7.4. Uses the shared `SSLConfiguration` model from connection settings. Supports connect, query, streaming results, schema browsing (tables, columns, indexes, foreign keys), database and schema switching, and explicit transactions.
- iOS: data browser, search, filter, and pagination now render correct SQL Server syntax (bracket-quoted identifiers, `OFFSET ... ROWS FETCH NEXT ... ROWS ONLY` pagination, `SELECT TOP 1` for cell value fetch).
- iOS: Settings > Sync now shows last sync time, a Sync Now button, and a Refresh from iCloud action that re-downloads every connection, group, and tag when items are missing on this device but visible on another.
Expand Down
159 changes: 136 additions & 23 deletions TablePro/Core/AI/AnthropicProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,21 @@ final class AnthropicProvider: ChatTransport {
private let apiKey: String
private let model: String
private let maxOutputTokens: Int
private let configuredEffort: ReasoningEffort?
private let session: URLSession

init(endpoint: String, apiKey: String, model: String = "", maxOutputTokens: Int = 4_096) {
init(
endpoint: String,
apiKey: String,
model: String = "",
maxOutputTokens: Int = 4_096,
reasoningEffort: ReasoningEffort? = nil
) {
self.endpoint = endpoint.normalizedEndpoint()
self.apiKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
self.model = model.trimmingCharacters(in: .whitespacesAndNewlines)
self.maxOutputTokens = maxOutputTokens
self.configuredEffort = reasoningEffort
self.session = URLSession(configuration: .ephemeral)
}

Expand Down Expand Up @@ -150,16 +158,25 @@ final class AnthropicProvider: ChatTransport {
request.setValue(apiKey, forHTTPHeaderField: "x-api-key")
request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version")

let effort = options.reasoningEffort ?? configuredEffort
let resolvedMaxTokens = options.maxOutputTokens
?? effort?.autoScaledMaxOutputTokens
?? maxOutputTokens

var body: [String: Any] = [
"model": options.model,
"max_tokens": options.maxOutputTokens ?? maxOutputTokens,
"max_tokens": resolvedMaxTokens,
"stream": stream
]

if let systemPrompt = options.systemPrompt {
body["system"] = systemPrompt
}

if let effort {
body["thinking"] = thinkingBody(for: effort, model: options.model, maxTokens: resolvedMaxTokens)
}

if !options.tools.isEmpty {
body["tools"] = try options.tools.map(Self.encodeToolSpec(_:))
}
Expand All @@ -173,10 +190,30 @@ final class AnthropicProvider: ChatTransport {
return request
}

/// Decodes one SSE line of the form `data: {...}` to a JSON object.
/// Returns `nil` for non-data lines, the `[DONE]` sentinel, and unparsable
/// payloads. Keeping this separate from `parseChunk` lets tests skip the
/// SSE framing and feed JSON dictionaries directly.
private func thinkingBody(for effort: ReasoningEffort, model: String, maxTokens: Int) -> [String: Any] {
if Self.modelSupportsAdaptiveThinking(model) {
var thinking: [String: Any] = ["type": "adaptive"]
if let adaptiveEffort = effort.anthropicAdaptiveEffort {
thinking["effort"] = adaptiveEffort
}
return thinking
}
let budget = min(effort.anthropicBudgetTokens ?? 4_096, max(maxTokens - 1, 1_024))
return [
"type": "enabled",
"budget_tokens": budget
]
}

private static func modelSupportsAdaptiveThinking(_ model: String) -> Bool {
let lowered = model.lowercased()
if lowered.contains("opus-4-7") || lowered.contains("opus-4.7") { return true }
if lowered.contains("opus-4-6") || lowered.contains("opus-4.6") { return true }
if lowered.contains("sonnet-4-6") || lowered.contains("sonnet-4.6") { return true }
if lowered.contains("haiku-4-5") || lowered.contains("haiku-4.5") { return true }
return false
}

static func decodeStreamLine(_ line: String) -> [String: Any]? {
guard line.hasPrefix("data: ") else { return nil }
let jsonString = String(line.dropFirst(6))
Expand All @@ -187,10 +224,6 @@ final class AnthropicProvider: ChatTransport {
return json
}

/// Translate a single Anthropic SSE event JSON into zero or more
/// `ChatStreamEvent`s. Mutates `state` to carry index→id mappings and
/// token counters across calls. Throws `AIProviderError.streamingFailed`
/// on `error` events.
static func parseChunk(
_ json: [String: Any],
state: inout AnthropicStreamState
Expand All @@ -199,31 +232,76 @@ final class AnthropicProvider: ChatTransport {
switch type {
case "content_block_start":
guard let index = json["index"] as? Int,
let block = json["content_block"] as? [String: Any],
(block["type"] as? String) == "tool_use",
let blockId = block["id"] as? String,
let blockName = block["name"] as? String
else { return [] }
state.toolUseIdsByIndex[index] = blockId
return [.toolUseStart(id: blockId, name: blockName)]
let block = json["content_block"] as? [String: Any] else { return [] }
let blockType = block["type"] as? String
switch blockType {
case "tool_use":
guard let blockId = block["id"] as? String,
let blockName = block["name"] as? String else { return [] }
state.toolUseIdsByIndex[index] = blockId
return [.toolUseStart(id: blockId, name: blockName)]
case "thinking", "redacted_thinking":
let synthID = "thinking_\(UUID().uuidString.prefix(8))"
let resolvedType = blockType ?? "thinking"
state.thinkingIdsByIndex[index] = synthID
state.thinkingTypeByIndex[index] = resolvedType
if resolvedType == "redacted_thinking", let initialData = block["data"] as? String {
state.thinkingRedactedDataByIndex[index] = initialData
} else if let initialSignature = block["signature"] as? String {
state.thinkingSignatureByIndex[index] = initialSignature
}
return [.reasoningStart(id: synthID)]
default:
return []
}
case "content_block_delta":
guard let delta = json["delta"] as? [String: Any] else { return [] }
if (delta["type"] as? String) == "input_json_delta" {
let deltaType = delta["type"] as? String
if deltaType == "input_json_delta" {
guard let index = json["index"] as? Int,
let id = state.toolUseIdsByIndex[index],
let partial = delta["partial_json"] as? String
else { return [] }
return [.toolUseDelta(id: id, inputJSONDelta: partial)]
}
if deltaType == "thinking_delta" {
guard let index = json["index"] as? Int,
let id = state.thinkingIdsByIndex[index],
let thinking = delta["thinking"] as? String, !thinking.isEmpty
else { return [] }
return [.reasoningDelta(id: id, text: thinking)]
}
if deltaType == "signature_delta" {
guard let index = json["index"] as? Int,
let signature = delta["signature"] as? String else { return [] }
state.thinkingSignatureByIndex[index, default: ""] += signature
return []
}
if let text = delta["text"] as? String {
return [.textDelta(text)]
}
return []
case "content_block_stop":
guard let index = json["index"] as? Int,
let id = state.toolUseIdsByIndex.removeValue(forKey: index)
else { return [] }
return [.toolUseEnd(id: id)]
guard let index = json["index"] as? Int else { return [] }
if let toolID = state.toolUseIdsByIndex.removeValue(forKey: index) {
return [.toolUseEnd(id: toolID)]
}
if let thinkingID = state.thinkingIdsByIndex.removeValue(forKey: index) {
let blockType = state.thinkingTypeByIndex.removeValue(forKey: index) ?? "thinking"
let payload = blockType == "redacted_thinking"
? state.thinkingRedactedDataByIndex.removeValue(forKey: index) ?? ""
: state.thinkingSignatureByIndex.removeValue(forKey: index) ?? ""
let opaque: ReasoningOpaque? = payload.isEmpty
? nil
: ReasoningOpaque(
kind: .anthropicSignature,
itemID: thinkingID,
value: payload,
blockType: blockType
)
return [.reasoningEnd(id: thinkingID, opaque: opaque)]
}
return []
case "message_start":
if let message = json["message"] as? [String: Any],
let usage = message["usage"] as? [String: Any],
Expand Down Expand Up @@ -260,7 +338,7 @@ final class AnthropicProvider: ChatTransport {
let blocks = turn.blocks
let needsTypedBlocks = blocks.contains { block in
switch block.kind {
case .toolUse, .toolResult:
case .toolUse, .toolResult, .reasoning, .image:
return true
case .text, .attachment:
return false
Expand Down Expand Up @@ -302,6 +380,37 @@ final class AnthropicProvider: ChatTransport {
return encoded
case .attachment:
return nil
case .reasoning(let reasoning):
guard let opaque = reasoning.opaque, opaque.kind == .anthropicSignature else { return nil }
if opaque.blockType == "redacted_thinking" {
return ["type": "redacted_thinking", "data": opaque.value]
}
return [
"type": "thinking",
"thinking": reasoning.text ?? "",
"signature": opaque.value
]
case .image(let input):
switch input.source {
case .cacheFile(let filename, let mediaType):
guard let data = AIImageCache.shared.read(filename: filename) else { return nil }
return [
"type": "image",
"source": [
"type": "base64",
"media_type": mediaType,
"data": data.base64EncodedString()
] as [String: Any]
]
case .remoteURL(let url, _):
return [
"type": "image",
"source": [
"type": "url",
"url": url.absoluteString
] as [String: Any]
]
}
}
}
}
Expand All @@ -311,6 +420,10 @@ struct AnthropicStreamState {
var inputTokens: Int = 0
var outputTokens: Int = 0
var toolUseIdsByIndex: [Int: String] = [:]
var thinkingIdsByIndex: [Int: String] = [:]
var thinkingTypeByIndex: [Int: String] = [:]
var thinkingSignatureByIndex: [Int: String] = [:]
var thinkingRedactedDataByIndex: [Int: String] = [:]

func finalUsageEvent() -> ChatStreamEvent? {
guard inputTokens > 0 || outputTokens > 0 else { return nil }
Expand Down
44 changes: 44 additions & 0 deletions TablePro/Core/AI/Chat/ChatImageInput.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
//
// ChatImageInput.swift
// TablePro
//

import Foundation

struct ChatImageInput: Codable, Equatable, Sendable {
enum Source: Codable, Equatable, Sendable {
case cacheFile(filename: String, mediaType: String)
case remoteURL(URL, mediaType: String)
}

var source: Source
var detailHint: DetailHint

init(source: Source, detailHint: DetailHint = .auto) {
self.source = source
self.detailHint = detailHint
}

var mediaType: String {
switch source {
case .cacheFile(_, let mediaType): return mediaType
case .remoteURL(_, let mediaType): return mediaType
}
}

func imageURLString() -> String? {
switch source {
case .cacheFile(let filename, let mediaType):
guard let data = AIImageCache.shared.read(filename: filename) else { return nil }
return "data:\(mediaType);base64,\(data.base64EncodedString())"
case .remoteURL(let url, _):
return url.absoluteString
}
}
}

enum DetailHint: String, Codable, Sendable, CaseIterable, Identifiable {
case auto, low, high

var id: String { rawValue }
}
70 changes: 42 additions & 28 deletions TablePro/Core/AI/Chat/ChatToolSchemaBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,44 +6,58 @@
import Foundation

enum ChatToolSchemaBuilder {
static func object(properties: [String: JsonValue], required: [String] = []) -> JsonValue {
var fields: [String: JsonValue] = [
static func object(
properties: [String: JsonValue],
required: [String]? = nil
) -> JsonValue {
let resolvedRequired = required ?? Array(properties.keys)
return .object([
"type": .string("object"),
"properties": .object(properties)
]
if !required.isEmpty {
fields["required"] = .array(required.map(JsonValue.string))
}
return .object(fields)
"properties": .object(properties),
"required": .array(resolvedRequired.map(JsonValue.string)),
"additionalProperties": .bool(false)
])
}

static func string(description: String) -> JsonValue {
.object([
"type": .string("string"),
"description": .string(description)
])
static func string(description: String, optional: Bool = false) -> JsonValue {
scalar("string", description: description, optional: optional)
}

static func enumString(_ values: [String], description: String) -> JsonValue {
.object([
"type": .string("string"),
"enum": .array(values.map(JsonValue.string)),
"description": .string(description)
static func enumString(_ values: [String], description: String, optional: Bool = false) -> JsonValue {
var members = values.map(JsonValue.string)
if optional {
members.append(.null)
}
return scalar("string", description: description, optional: optional, extras: [
"enum": .array(members)
])
}

static func boolean(description: String) -> JsonValue {
.object([
"type": .string("boolean"),
"description": .string(description)
])
static func boolean(description: String, optional: Bool = false) -> JsonValue {
scalar("boolean", description: description, optional: optional)
}

static func integer(description: String) -> JsonValue {
.object([
"type": .string("integer"),
static func integer(description: String, optional: Bool = false) -> JsonValue {
scalar("integer", description: description, optional: optional)
}

private static func scalar(
_ typeName: String,
description: String,
optional: Bool,
extras: [String: JsonValue] = [:]
) -> JsonValue {
let baseType: JsonValue = optional
? .array([.string(typeName), .string("null")])
: .string(typeName)
var fields: [String: JsonValue] = [
"type": baseType,
"description": .string(description)
])
]
for (key, value) in extras {
fields[key] = value
}
return .object(fields)
}
}

Expand All @@ -53,6 +67,6 @@ extension ChatToolSchemaBuilder {
}

static var schemaName: JsonValue {
string(description: "Schema name (uses current if omitted)")
string(description: "Schema name (uses current if omitted)", optional: true)
}
}
Loading
Loading