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

### Fixed

- AI Chat: Gemini provider now sends tool schemas with `additionalProperties` stripped and optional fields rewritten from `type: [X, null]` to `type: X, nullable: true`, fixing 400 errors when sending a message with tools enabled.
- AI Chat: Gemini provider now round-trips `thoughtSignature` on function calls, fixing the second-round 400 error after a tool runs.
- MySQL/MariaDB: `BIT(N)` columns now display as decimal numbers (`0`, `1`, `255`) instead of raw bytes that showed up as control characters like `^A` in the data grid. (#1272)
- ClickHouse, BigQuery, CloudflareD1, LibSQL, Etcd, and DynamoDB: long-running queries no longer fail at 30 seconds when Settings > Query timeout is set higher. The HTTP transport now uses the configured query timeout plus a 30-second grace, so the server's `max_execution_time` (or equivalent) fires before the client gives up. Setting "No limit" raises the transport ceiling to 1 hour. (#1267)
- AI Chat: DeepSeek V4 thinking content (`reasoning_content`) is now captured during streaming and passed back in subsequent turns, fixing 400 errors when using deepseek-v4-pro or deepseek-v4-flash.
Expand Down
2 changes: 1 addition & 1 deletion TablePro/Core/AI/Chat/ChatTransport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ struct ChatToolSpec: Codable, Equatable, Sendable {

enum ChatStreamEvent: Sendable {
case textDelta(String)
case toolUseStart(id: String, name: String)
case toolUseStart(id: String, name: String, providerMetadata: [String: String]? = nil)
case toolUseDelta(id: String, inputJSONDelta: String)
case toolUseEnd(id: String)
case usage(AITokenUsage)
Expand Down
17 changes: 15 additions & 2 deletions TablePro/Core/AI/Chat/ChatTurn.swift
Original file line number Diff line number Diff line change
Expand Up @@ -396,12 +396,23 @@ struct ToolUseBlock: Codable, Equatable, Sendable {
let name: String
let input: JsonValue
var approvalState: ToolApprovalState
/// Opaque provider-specific data that must round-trip with the call (e.g., Gemini's
/// `thoughtSignature` required when echoing a function call alongside its response).
/// Keys are provider-defined; unknown providers ignore them.
var providerMetadata: [String: String]?

init(id: String, name: String, input: JsonValue, approvalState: ToolApprovalState = .approved) {
init(
id: String,
name: String,
input: JsonValue,
approvalState: ToolApprovalState = .approved,
providerMetadata: [String: String]? = nil
) {
self.id = id
self.name = name
self.input = input
self.approvalState = approvalState
self.providerMetadata = providerMetadata
}

init(from decoder: Decoder) throws {
Expand All @@ -410,6 +421,7 @@ struct ToolUseBlock: Codable, Equatable, Sendable {
name = try container.decode(String.self, forKey: .name)
input = try container.decode(JsonValue.self, forKey: .input)
approvalState = try container.decodeIfPresent(ToolApprovalState.self, forKey: .approvalState) ?? .approved
providerMetadata = try container.decodeIfPresent([String: String].self, forKey: .providerMetadata)
}

func encode(to encoder: Encoder) throws {
Expand All @@ -418,10 +430,11 @@ struct ToolUseBlock: Codable, Equatable, Sendable {
try container.encode(name, forKey: .name)
try container.encode(input, forKey: .input)
try container.encode(approvalState, forKey: .approvalState)
try container.encodeIfPresent(providerMetadata, forKey: .providerMetadata)
}

private enum CodingKeys: String, CodingKey {
case id, name, input, approvalState
case id, name, input, approvalState, providerMetadata
}
}

Expand Down
68 changes: 64 additions & 4 deletions TablePro/Core/AI/GeminiProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,8 @@ final class GeminiProvider: ChatTransport {
"name": tool.name,
"description": tool.description
]
entry["parameters"] = try tool.inputSchema.jsonObject()
let sanitized = Self.sanitizeSchemaForGemini(tool.inputSchema)
entry["parameters"] = try sanitized.jsonObject()
return entry
}
body["tools"] = [["functionDeclarations": declarations]]
Expand Down Expand Up @@ -219,12 +220,16 @@ final class GeminiProvider: ChatTransport {
continue
case .toolUse(let useBlock):
let argsObject = (try? useBlock.input.jsonObject()) ?? [String: Any]()
parts.append([
var partEntry: [String: Any] = [
"functionCall": [
"name": useBlock.name,
"args": argsObject
]
])
]
if let signature = useBlock.providerMetadata?["thoughtSignature"], !signature.isEmpty {
partEntry["thoughtSignature"] = signature
}
parts.append(partEntry)
case .toolResult(let resultBlock):
let toolName = resolveToolName(
forToolUseId: resultBlock.toolUseId,
Expand Down Expand Up @@ -295,7 +300,11 @@ final class GeminiProvider: ChatTransport {
let id = idGenerator()
let argsObject = functionCall["args"] ?? [String: Any]()
let argsString = encodeArgsToJSONString(argsObject)
events.append(.toolUseStart(id: id, name: name))
var metadata: [String: String]?
if let signature = part["thoughtSignature"] as? String, !signature.isEmpty {
metadata = ["thoughtSignature": signature]
}
events.append(.toolUseStart(id: id, name: name, providerMetadata: metadata))
events.append(.toolUseDelta(id: id, inputJSONDelta: argsString))
events.append(.toolUseEnd(id: id))
}
Expand Down Expand Up @@ -325,6 +334,57 @@ final class GeminiProvider: ChatTransport {
return "{}"
}
}

// MARK: - Schema Sanitization

/// Strip JSON Schema features Gemini's `function_declarations` API rejects.
/// Removes `additionalProperties` keys at any depth. Rewrites optional fields
/// expressed as `type: ["X", "null"]` to `type: "X"` plus `nullable: true`,
/// which is the form Gemini accepts.
static func sanitizeSchemaForGemini(_ schema: JsonValue) -> JsonValue {
switch schema {
case .object(let fields):
var rewritten: [String: JsonValue] = [:]
var nullable = false

for (key, value) in fields {
if key == "additionalProperties" {
continue
}
if key == "type", case .array(let members) = value {
let nonNull = members.compactMap { member -> String? in
if case .string(let typeName) = member, typeName != "null" {
return typeName
}
if case .string("null") = member {
nullable = true
return nil
}
return nil
}
if nonNull.count == 1, let primary = nonNull.first {
rewritten["type"] = .string(primary)
} else {
rewritten["type"] = .array(nonNull.map(JsonValue.string))
}
continue
}
rewritten[key] = sanitizeSchemaForGemini(value)
}

if nullable {
rewritten["nullable"] = .bool(true)
}

return .object(rewritten)

case .array(let items):
return .array(items.map(sanitizeSchemaForGemini))

default:
return schema
}
}
}

/// Mutable state carried across `GeminiProvider.parseChunk` calls.
Expand Down
9 changes: 9 additions & 0 deletions TablePro/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -16648,6 +16648,9 @@
},
"Drop Database…" : {

},
"Drop Failed" : {

},
"Drop Foreign Table" : {

Expand Down Expand Up @@ -37858,6 +37861,9 @@
}
}
}
},
"Remove “%@”?" : {

},
"Remove %@?" : {
"localizations" : {
Expand Down Expand Up @@ -47038,6 +47044,9 @@
},
"The previous code wasn't accepted. Wait for your authenticator to refresh, then enter the new code." : {

},
"The provider configuration and stored API key will be deleted." : {

},
"The selected backup file is not readable." : {

Expand Down
21 changes: 17 additions & 4 deletions TablePro/ViewModels/AIChatViewModel+Streaming.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ extension AIChatViewModel {
let toolUseOrder: [String]
let toolUseNames: [String: String]
let toolUseInputs: [String: String]
let toolUseMetadata: [String: [String: String]]
let cancelled: Bool
}

Expand Down Expand Up @@ -144,7 +145,8 @@ extension AIChatViewModel {
let assembled = Self.assembleToolUseBlocks(
order: round.toolUseOrder,
names: round.toolUseNames,
inputs: round.toolUseInputs
inputs: round.toolUseInputs,
metadata: round.toolUseMetadata
)
let context = await MainActor.run {
ChatToolContext(
Expand Down Expand Up @@ -244,6 +246,7 @@ extension AIChatViewModel {
var toolUseOrder: [String] = []
var toolUseNames: [String: String] = [:]
var toolUseInputs: [String: String] = [:]
var toolUseMetadata: [String: [String: String]] = [:]
var reasoningIDMap: [String: UUID] = [:]
let flushInterval: ContinuousClock.Duration = .milliseconds(150)
var lastFlushTime: ContinuousClock.Instant = .now
Expand All @@ -255,7 +258,7 @@ extension AIChatViewModel {
pendingContent += token
case .usage(let usage):
pendingUsage = usage
case .toolUseStart(let id, let name):
case .toolUseStart(let id, let name, let providerMetadata):
if !pendingContent.isEmpty {
await self.flushPending(content: pendingContent, usage: pendingUsage, into: assistantID)
pendingContent = ""
Expand All @@ -270,6 +273,9 @@ extension AIChatViewModel {
toolUseInputs[id] = ""
}
toolUseNames[id] = name
if let providerMetadata, !providerMetadata.isEmpty {
toolUseMetadata[id] = providerMetadata
}
case .toolUseDelta(let id, let inputJSONDelta):
toolUseInputs[id, default: ""] += inputJSONDelta
case .toolUseEnd:
Expand Down Expand Up @@ -309,6 +315,7 @@ extension AIChatViewModel {
toolUseOrder: toolUseOrder,
toolUseNames: toolUseNames,
toolUseInputs: toolUseInputs,
toolUseMetadata: toolUseMetadata,
cancelled: Task.isCancelled
)
}
Expand Down Expand Up @@ -465,7 +472,8 @@ extension AIChatViewModel {
nonisolated static func assembleToolUseBlocks(
order: [String],
names: [String: String],
inputs: [String: String]
inputs: [String: String],
metadata: [String: [String: String]] = [:]
) -> [ToolUseBlock] {
order.compactMap { id -> ToolUseBlock? in
guard let name = names[id] else { return nil }
Expand All @@ -479,7 +487,12 @@ extension AIChatViewModel {
} else {
inputValue = .object([:])
}
return ToolUseBlock(id: id, name: name, input: inputValue)
return ToolUseBlock(
id: id,
name: name,
input: inputValue,
providerMetadata: metadata[id]
)
}
}

Expand Down
20 changes: 17 additions & 3 deletions TablePro/ViewModels/AIChatViewModel+ToolApproval.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,13 @@ extension AIChatViewModel {
guard let self else { return assembledBlocks }
let initial = assembledBlocks.map { block -> ToolUseBlock in
let state = self.computeInitialApprovalState(for: block.name)
return ToolUseBlock(id: block.id, name: block.name, input: block.input, approvalState: state)
return ToolUseBlock(
id: block.id,
name: block.name,
input: block.input,
approvalState: state,
providerMetadata: block.providerMetadata
)
}
self.appendPendingToolUseBlocks(initial, assistantID: assistantID)
return initial
Expand Down Expand Up @@ -60,7 +66,11 @@ extension AIChatViewModel {
self?.updateApprovalState(blockID: block.id, newState: finalState, assistantID: assistantID)
}
resolved.append(ToolUseBlock(
id: block.id, name: block.name, input: block.input, approvalState: finalState
id: block.id,
name: block.name,
input: block.input,
approvalState: finalState,
providerMetadata: block.providerMetadata
))
}
return resolved
Expand Down Expand Up @@ -142,7 +152,11 @@ extension AIChatViewModel {
) async {
let initialState = computeInitialApprovalState(for: block.name)
let pendingBlock = ToolUseBlock(
id: block.id, name: block.name, input: block.input, approvalState: initialState
id: block.id,
name: block.name,
input: block.input,
approvalState: initialState,
providerMetadata: block.providerMetadata
)
appendPendingToolUseBlocks([pendingBlock], assistantID: assistantID)

Expand Down
2 changes: 1 addition & 1 deletion TableProTests/Core/AI/AnthropicProviderParserTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ struct AnthropicProviderParserTests {
]
], state: &state)
#expect(events.count == 1)
if case .toolUseStart(let id, let name) = events.first {
if case .toolUseStart(let id, let name, _) = events.first {
#expect(id == "toolu_abc")
#expect(name == "list_tables")
} else {
Expand Down
Loading
Loading