From 6529d0c4fa3c81358a37e71fecc91edc95193567 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 13 May 2026 14:27:47 +0700 Subject: [PATCH 01/10] feat(ai-providers): migrate OpenAI to Responses API + reasoning + images (#1112) --- CHANGELOG.md | 6 + TablePro/Core/AI/AnthropicProvider.swift | 151 ++++++- TablePro/Core/AI/Chat/ChatImageInput.swift | 44 ++ .../Core/AI/Chat/ChatToolSchemaBuilder.swift | 66 +-- TablePro/Core/AI/Chat/ChatTransport.swift | 28 +- TablePro/Core/AI/Chat/ChatTurn.swift | 74 +++- TablePro/Core/AI/Chat/Reasoning.swift | 99 +++++ .../AI/Chat/Tools/DescribeTableChatTool.swift | 3 +- .../AI/Chat/Tools/ExecuteQueryChatTool.swift | 19 +- .../Tools/GetConnectionStatusChatTool.swift | 3 +- .../AI/Chat/Tools/GetTableDDLChatTool.swift | 3 +- .../AI/Chat/Tools/ListTablesChatTool.swift | 8 +- TablePro/Core/AI/GeminiProvider.swift | 2 +- TablePro/Core/AI/Images/AIImageCache.swift | 74 ++++ .../Core/AI/Images/ChatImageConverter.swift | 176 ++++++++ .../Core/AI/OpenAICompatibleProvider.swift | 32 ++ .../Core/AI/OpenAIResponsesProvider.swift | 388 ++++++++++++++++++ .../AI/Registry/AIProviderDescriptor.swift | 61 ++- .../AI/Registry/AIProviderRegistration.swift | 87 +++- TablePro/Models/AI/AIModels.swift | 8 +- .../AIChatViewModel+Streaming.swift | 52 ++- TablePro/ViewModels/AIChatViewModel.swift | 36 +- .../AIChat/AIChatComposerImageChip.swift | 34 ++ .../Views/AIChat/AIChatImageBlockView.swift | 22 + TablePro/Views/AIChat/AIChatMessageView.swift | 10 + TablePro/Views/AIChat/AIChatPanelView.swift | 30 +- .../AIChat/AIChatReasoningBlockView.swift | 67 +++ .../Views/AIChat/ChatComposerTextView.swift | 31 ++ TablePro/Views/AIChat/ChatComposerView.swift | 89 +++- .../Views/AIChat/ChatImageThumbnailView.swift | 44 ++ .../Settings/AIProviderDetailSheet.swift | 155 +++++-- ...OpenAIResponsesProviderEncodingTests.swift | 118 ++++++ .../OpenAIResponsesProviderParserTests.swift | 162 ++++++++ .../Core/AI/StrictToolSchemaTests.swift | 87 ++++ 34 files changed, 2144 insertions(+), 125 deletions(-) create mode 100644 TablePro/Core/AI/Chat/ChatImageInput.swift create mode 100644 TablePro/Core/AI/Chat/Reasoning.swift create mode 100644 TablePro/Core/AI/Images/AIImageCache.swift create mode 100644 TablePro/Core/AI/Images/ChatImageConverter.swift create mode 100644 TablePro/Core/AI/OpenAIResponsesProvider.swift create mode 100644 TablePro/Views/AIChat/AIChatComposerImageChip.swift create mode 100644 TablePro/Views/AIChat/AIChatImageBlockView.swift create mode 100644 TablePro/Views/AIChat/AIChatReasoningBlockView.swift create mode 100644 TablePro/Views/AIChat/ChatImageThumbnailView.swift create mode 100644 TableProTests/Core/AI/OpenAIResponsesProviderEncodingTests.swift create mode 100644 TableProTests/Core/AI/OpenAIResponsesProviderParserTests.swift create mode 100644 TableProTests/Core/AI/StrictToolSchemaTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index fa6d698f6..3eaf5602a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) - File > Backup Dump… and Restore Dump… for PostgreSQL and Redshift connections, running `pg_dump -Fc` and `pg_restore --no-owner --no-acl` with live progress, cancel, SSH tunnel reuse, and custom binary paths under Settings > Terminal > CLI Paths (#1211). ### Changed diff --git a/TablePro/Core/AI/AnthropicProvider.swift b/TablePro/Core/AI/AnthropicProvider.swift index 6b9274e35..fdf764f12 100644 --- a/TablePro/Core/AI/AnthropicProvider.swift +++ b/TablePro/Core/AI/AnthropicProvider.swift @@ -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) } @@ -150,9 +158,14 @@ 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 ] @@ -160,6 +173,10 @@ final class AnthropicProvider: ChatTransport { 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(_:)) } @@ -173,6 +190,30 @@ final class AnthropicProvider: ChatTransport { return request } + 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 + } + /// 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 @@ -199,31 +240,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], @@ -260,7 +346,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 @@ -302,6 +388,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] + ] + } } } } @@ -311,6 +428,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 } diff --git a/TablePro/Core/AI/Chat/ChatImageInput.swift b/TablePro/Core/AI/Chat/ChatImageInput.swift new file mode 100644 index 000000000..7636d9191 --- /dev/null +++ b/TablePro/Core/AI/Chat/ChatImageInput.swift @@ -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 } +} diff --git a/TablePro/Core/AI/Chat/ChatToolSchemaBuilder.swift b/TablePro/Core/AI/Chat/ChatToolSchemaBuilder.swift index 4437b1661..2971056f6 100644 --- a/TablePro/Core/AI/Chat/ChatToolSchemaBuilder.swift +++ b/TablePro/Core/AI/Chat/ChatToolSchemaBuilder.swift @@ -6,44 +6,54 @@ 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 { + scalar("string", description: description, optional: optional, extras: [ + "enum": .array(values.map(JsonValue.string)) ]) } - 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, optional: Bool = false) -> JsonValue { + scalar("integer", description: description, optional: optional) } - static func integer(description: String) -> JsonValue { - .object([ - "type": .string("integer"), + 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) } } @@ -53,6 +63,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) } } diff --git a/TablePro/Core/AI/Chat/ChatTransport.swift b/TablePro/Core/AI/Chat/ChatTransport.swift index df4a944ba..7dd0bf3a5 100644 --- a/TablePro/Core/AI/Chat/ChatTransport.swift +++ b/TablePro/Core/AI/Chat/ChatTransport.swift @@ -22,19 +22,22 @@ struct ChatTransportOptions: Sendable { var maxOutputTokens: Int? var temperature: Double? var tools: [ChatToolSpec] + var reasoningEffort: ReasoningEffort? init( model: String, systemPrompt: String? = nil, maxOutputTokens: Int? = nil, temperature: Double? = nil, - tools: [ChatToolSpec] = [] + tools: [ChatToolSpec] = [], + reasoningEffort: ReasoningEffort? = nil ) { self.model = model self.systemPrompt = systemPrompt self.maxOutputTokens = maxOutputTokens self.temperature = temperature self.tools = tools + self.reasoningEffort = reasoningEffort } } @@ -42,6 +45,26 @@ struct ChatToolSpec: Codable, Equatable, Sendable { let name: String let description: String let inputSchema: JsonValue + let strict: Bool + + init(name: String, description: String, inputSchema: JsonValue, strict: Bool = true) { + self.name = name + self.description = description + self.inputSchema = inputSchema + self.strict = strict + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + name = try container.decode(String.self, forKey: .name) + description = try container.decode(String.self, forKey: .description) + inputSchema = try container.decode(JsonValue.self, forKey: .inputSchema) + strict = try container.decodeIfPresent(Bool.self, forKey: .strict) ?? true + } + + private enum CodingKeys: String, CodingKey { + case name, description, inputSchema, strict + } } enum ChatStreamEvent: Sendable { @@ -51,6 +74,9 @@ enum ChatStreamEvent: Sendable { case toolUseEnd(id: String) case usage(AITokenUsage) case toolInvocationRequest(block: ToolUseBlock, replyToken: ToolReplyToken) + case reasoningStart(id: String) + case reasoningDelta(id: String, text: String) + case reasoningEnd(id: String, opaque: ReasoningOpaque?) } final class ToolReplyToken: Sendable { diff --git a/TablePro/Core/AI/Chat/ChatTurn.swift b/TablePro/Core/AI/Chat/ChatTurn.swift index 2b8150291..feb56a647 100644 --- a/TablePro/Core/AI/Chat/ChatTurn.swift +++ b/TablePro/Core/AI/Chat/ChatTurn.swift @@ -17,6 +17,8 @@ enum ChatContentBlockKind: Sendable, Equatable { case toolUse(ToolUseBlock) case toolResult(ToolResultBlock) case attachment(ContextItem) + case reasoning(ReasoningBlock) + case image(ChatImageInput) } @MainActor @Observable @@ -36,6 +38,18 @@ final class ChatContentBlock: Identifiable { kind = .text(existing + chunk) } + func appendReasoningText(_ chunk: String) { + guard !chunk.isEmpty, case .reasoning(var block) = kind else { return } + block.text = (block.text ?? "") + chunk + kind = .reasoning(block) + } + + func setReasoningOpaque(_ opaque: ReasoningOpaque?) { + guard case .reasoning(var block) = kind else { return } + block.opaque = opaque + kind = .reasoning(block) + } + func setKind(_ newKind: ChatContentBlockKind) { kind = newKind } @@ -65,6 +79,14 @@ extension ChatContentBlock { static func attachment(_ item: ContextItem) -> ChatContentBlock { ChatContentBlock(kind: .attachment(item)) } + + static func reasoning(_ block: ReasoningBlock = ReasoningBlock(), isStreaming: Bool = false) -> ChatContentBlock { + ChatContentBlock(kind: .reasoning(block), isStreaming: isStreaming) + } + + static func image(_ input: ChatImageInput) -> ChatContentBlock { + ChatContentBlock(kind: .image(input)) + } } @MainActor @@ -147,6 +169,36 @@ struct ChatTurn: Identifiable { blocks.append(block) } + @discardableResult + mutating func appendReasoningDelta(providerBlockID: String, text: String, idMap: inout [String: UUID]) -> UUID { + if let existingUUID = idMap[providerBlockID], + let existingBlock = blocks.first(where: { $0.id == existingUUID }) { + existingBlock.appendReasoningText(text) + return existingUUID + } + finishStreamingTextBlock() + let newUUID = UUID() + idMap[providerBlockID] = newUUID + let initial = ReasoningBlock(text: text.isEmpty ? nil : text) + blocks.append(ChatContentBlock(id: newUUID, kind: .reasoning(initial), isStreaming: true)) + return newUUID + } + + mutating func startReasoningBlock(providerBlockID: String, idMap: inout [String: UUID]) { + if idMap[providerBlockID] != nil { return } + finishStreamingTextBlock() + let newUUID = UUID() + idMap[providerBlockID] = newUUID + blocks.append(ChatContentBlock(id: newUUID, kind: .reasoning(ReasoningBlock()), isStreaming: true)) + } + + mutating func finalizeReasoningBlock(providerBlockID: String, opaque: ReasoningOpaque?, idMap: inout [String: UUID]) { + guard let blockUUID = idMap.removeValue(forKey: providerBlockID), + let block = blocks.first(where: { $0.id == blockUUID }) else { return } + block.setReasoningOpaque(opaque) + block.finishStreaming() + } + private static func coalesceAdjacentText(_ blocks: [ChatContentBlock]) -> [ChatContentBlock] { var result: [ChatContentBlock] = [] result.reserveCapacity(blocks.count) @@ -197,12 +249,20 @@ struct ChatContentBlockWire: Codable, Equatable, Sendable, Identifiable { ChatContentBlockWire(kind: .attachment(item)) } + static func reasoning(_ block: ReasoningBlock) -> ChatContentBlockWire { + ChatContentBlockWire(kind: .reasoning(block)) + } + + static func image(_ input: ChatImageInput) -> ChatContentBlockWire { + ChatContentBlockWire(kind: .image(input)) + } + private enum CodingKeys: String, CodingKey { - case blockId, kind, text, toolUse, toolResult, attachment + case blockId, kind, text, toolUse, toolResult, attachment, reasoning, image } private enum KindMarker: String, Codable { - case text, toolUse, toolResult, attachment + case text, toolUse, toolResult, attachment, reasoning, image } init(from decoder: Decoder) throws { @@ -219,6 +279,10 @@ struct ChatContentBlockWire: Codable, Equatable, Sendable, Identifiable { resolvedKind = .toolResult(try container.decode(ToolResultBlock.self, forKey: .toolResult)) case .attachment: resolvedKind = .attachment(try container.decode(ContextItem.self, forKey: .attachment)) + case .reasoning: + resolvedKind = .reasoning(try container.decode(ReasoningBlock.self, forKey: .reasoning)) + case .image: + resolvedKind = .image(try container.decode(ChatImageInput.self, forKey: .image)) } self.init(id: resolvedID, kind: resolvedKind) } @@ -239,6 +303,12 @@ struct ChatContentBlockWire: Codable, Equatable, Sendable, Identifiable { case .attachment(let item): try container.encode(KindMarker.attachment, forKey: .kind) try container.encode(item, forKey: .attachment) + case .reasoning(let block): + try container.encode(KindMarker.reasoning, forKey: .kind) + try container.encode(block, forKey: .reasoning) + case .image(let input): + try container.encode(KindMarker.image, forKey: .kind) + try container.encode(input, forKey: .image) } } } diff --git a/TablePro/Core/AI/Chat/Reasoning.swift b/TablePro/Core/AI/Chat/Reasoning.swift new file mode 100644 index 000000000..17907dc17 --- /dev/null +++ b/TablePro/Core/AI/Chat/Reasoning.swift @@ -0,0 +1,99 @@ +// +// Reasoning.swift +// TablePro +// + +import Foundation + +enum ReasoningEffort: String, Codable, Sendable, CaseIterable, Identifiable { + case minimal + case low + case medium + case high + case xhigh + + var id: String { rawValue } + + var displayName: String { + switch self { + case .minimal: return String(localized: "Minimal") + case .low: return String(localized: "Low") + case .medium: return String(localized: "Medium") + case .high: return String(localized: "High") + case .xhigh: return String(localized: "Extra High") + } + } + + var openAIWireValue: String { rawValue } + + var anthropicAdaptiveEffort: String? { + switch self { + case .minimal: return nil + case .low: return "low" + case .medium: return "medium" + case .high: return "high" + case .xhigh: return "maximum" + } + } + + var anthropicBudgetTokens: Int? { + switch self { + case .minimal: return nil + case .low: return 2_048 + case .medium: return 8_192 + case .high: return 16_384 + case .xhigh: return 32_768 + } + } + + var autoScaledMaxOutputTokens: Int { + switch self { + case .minimal: return 4_096 + case .low: return 8_192 + case .medium: return 16_384 + case .high: return 32_768 + case .xhigh: return 65_536 + } + } +} + +struct ReasoningOpaque: Codable, Equatable, Sendable { + enum Kind: String, Codable, Sendable { + case anthropicSignature + case openAIEncrypted + } + + let kind: Kind + let itemID: String + let value: String + let blockType: String + + init(kind: Kind, itemID: String, value: String, blockType: String) { + self.kind = kind + self.itemID = itemID + self.value = value + self.blockType = blockType + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + kind = try container.decode(Kind.self, forKey: .kind) + itemID = try container.decodeIfPresent(String.self, forKey: .itemID) ?? "" + value = try container.decode(String.self, forKey: .value) + blockType = try container.decode(String.self, forKey: .blockType) + } + + private enum CodingKeys: String, CodingKey { + case kind, itemID, value, blockType + } +} + +struct ReasoningBlock: Codable, Equatable, Sendable { + var text: String? + var opaque: ReasoningOpaque? + + init(text: String? = nil, opaque: ReasoningOpaque? = nil) { + self.text = text + self.opaque = opaque + } +} diff --git a/TablePro/Core/AI/Chat/Tools/DescribeTableChatTool.swift b/TablePro/Core/AI/Chat/Tools/DescribeTableChatTool.swift index 2352fdcc1..28c4ea866 100644 --- a/TablePro/Core/AI/Chat/Tools/DescribeTableChatTool.swift +++ b/TablePro/Core/AI/Chat/Tools/DescribeTableChatTool.swift @@ -13,8 +13,7 @@ struct DescribeTableChatTool: ChatTool { "connection_id": ChatToolSchemaBuilder.connectionId, "table": ChatToolSchemaBuilder.string(description: "Table or view name"), "schema": ChatToolSchemaBuilder.schemaName - ], - required: ["table"] + ] ) let mode: ChatToolMode = .readOnly diff --git a/TablePro/Core/AI/Chat/Tools/ExecuteQueryChatTool.swift b/TablePro/Core/AI/Chat/Tools/ExecuteQueryChatTool.swift index efb9111d1..1d8d1194b 100644 --- a/TablePro/Core/AI/Chat/Tools/ExecuteQueryChatTool.swift +++ b/TablePro/Core/AI/Chat/Tools/ExecuteQueryChatTool.swift @@ -17,15 +17,22 @@ struct ExecuteQueryChatTool: ChatTool { "connection_id": ChatToolSchemaBuilder.connectionId, "query": ChatToolSchemaBuilder.string(description: "SQL or NoSQL query text"), "max_rows": ChatToolSchemaBuilder.integer( - description: "Maximum rows to return (default 500, max 10000)" + description: "Maximum rows to return (default 500, max 10000). Pass null to use default.", + optional: true ), "timeout_seconds": ChatToolSchemaBuilder.integer( - description: "Query timeout in seconds (default 30, max 300)" + description: "Query timeout in seconds (default 30, max 300). Pass null to use default.", + optional: true ), - "database": ChatToolSchemaBuilder.string(description: "Switch to this database before executing"), - "schema": ChatToolSchemaBuilder.string(description: "Switch to this schema before executing") - ], - required: ["connection_id", "query"] + "database": ChatToolSchemaBuilder.string( + description: "Switch to this database before executing. Pass null to use current.", + optional: true + ), + "schema": ChatToolSchemaBuilder.string( + description: "Switch to this schema before executing. Pass null to use current.", + optional: true + ) + ] ) let mode: ChatToolMode = .write diff --git a/TablePro/Core/AI/Chat/Tools/GetConnectionStatusChatTool.swift b/TablePro/Core/AI/Chat/Tools/GetConnectionStatusChatTool.swift index ac71531e2..190d6729f 100644 --- a/TablePro/Core/AI/Chat/Tools/GetConnectionStatusChatTool.swift +++ b/TablePro/Core/AI/Chat/Tools/GetConnectionStatusChatTool.swift @@ -11,8 +11,7 @@ struct GetConnectionStatusChatTool: ChatTool { let inputSchema: JsonValue = ChatToolSchemaBuilder.object( properties: [ "connection_id": ChatToolSchemaBuilder.connectionId - ], - required: ["connection_id"] + ] ) let mode: ChatToolMode = .readOnly diff --git a/TablePro/Core/AI/Chat/Tools/GetTableDDLChatTool.swift b/TablePro/Core/AI/Chat/Tools/GetTableDDLChatTool.swift index dc0e06768..34fb9c192 100644 --- a/TablePro/Core/AI/Chat/Tools/GetTableDDLChatTool.swift +++ b/TablePro/Core/AI/Chat/Tools/GetTableDDLChatTool.swift @@ -13,8 +13,7 @@ struct GetTableDDLChatTool: ChatTool { "connection_id": ChatToolSchemaBuilder.connectionId, "table": ChatToolSchemaBuilder.string(description: "Table name"), "schema": ChatToolSchemaBuilder.schemaName - ], - required: ["table"] + ] ) let mode: ChatToolMode = .readOnly diff --git a/TablePro/Core/AI/Chat/Tools/ListTablesChatTool.swift b/TablePro/Core/AI/Chat/Tools/ListTablesChatTool.swift index c6ed8d952..d2043156c 100644 --- a/TablePro/Core/AI/Chat/Tools/ListTablesChatTool.swift +++ b/TablePro/Core/AI/Chat/Tools/ListTablesChatTool.swift @@ -11,10 +11,14 @@ struct ListTablesChatTool: ChatTool { let inputSchema: JsonValue = ChatToolSchemaBuilder.object( properties: [ "connection_id": ChatToolSchemaBuilder.connectionId, - "database": ChatToolSchemaBuilder.string(description: "Database name (uses current if omitted)"), + "database": ChatToolSchemaBuilder.string( + description: "Database name. Pass null to use current.", + optional: true + ), "schema": ChatToolSchemaBuilder.schemaName, "include_row_counts": ChatToolSchemaBuilder.boolean( - description: "Include approximate row counts (default false)" + description: "Include approximate row counts. Pass null to use default false.", + optional: true ) ] ) diff --git a/TablePro/Core/AI/GeminiProvider.swift b/TablePro/Core/AI/GeminiProvider.swift index c58d6ab6e..7a726847f 100644 --- a/TablePro/Core/AI/GeminiProvider.swift +++ b/TablePro/Core/AI/GeminiProvider.swift @@ -215,7 +215,7 @@ final class GeminiProvider: ChatTransport { case .text(let text): guard !text.isEmpty else { continue } parts.append(["text": text]) - case .attachment: + case .attachment, .reasoning, .image: continue case .toolUse(let useBlock): let argsObject = (try? useBlock.input.jsonObject()) ?? [String: Any]() diff --git a/TablePro/Core/AI/Images/AIImageCache.swift b/TablePro/Core/AI/Images/AIImageCache.swift new file mode 100644 index 000000000..388c9e000 --- /dev/null +++ b/TablePro/Core/AI/Images/AIImageCache.swift @@ -0,0 +1,74 @@ +// +// AIImageCache.swift +// TablePro +// + +import AppKit +import Foundation +import os + +final class AIImageCache: @unchecked Sendable { + static let shared = AIImageCache() + + private static let logger = Logger(subsystem: "com.TablePro", category: "AIImageCache") + + private let cacheDirectory: URL + + private init() { + let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first + ?? FileManager.default.temporaryDirectory + cacheDirectory = base + .appendingPathComponent("com.TablePro", isDirectory: true) + .appendingPathComponent("AIChatImages", isDirectory: true) + try? FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true) + } + + func store(data: Data, mediaType: String) -> String { + let ext = fileExtension(for: mediaType) + let filename = "\(UUID().uuidString).\(ext)" + let url = cacheDirectory.appendingPathComponent(filename) + do { + try data.write(to: url, options: .atomic) + } catch { + Self.logger.error("Failed to write image: \(error.localizedDescription, privacy: .public)") + } + return filename + } + + func read(filename: String) -> Data? { + try? Data(contentsOf: cacheDirectory.appendingPathComponent(filename)) + } + + func loadImage(filename: String) -> NSImage? { + guard let data = read(filename: filename) else { return nil } + return NSImage(data: data) + } + + func delete(filename: String) { + let url = cacheDirectory.appendingPathComponent(filename) + try? FileManager.default.removeItem(at: url) + } + + func purgeOlderThan(seconds: TimeInterval) { + let cutoff = Date().addingTimeInterval(-seconds) + let urls = (try? FileManager.default.contentsOfDirectory( + at: cacheDirectory, + includingPropertiesForKeys: [.contentModificationDateKey] + )) ?? [] + for url in urls { + let resources = try? url.resourceValues(forKeys: [.contentModificationDateKey]) + guard let date = resources?.contentModificationDate, date < cutoff else { continue } + try? FileManager.default.removeItem(at: url) + } + } + + private func fileExtension(for mediaType: String) -> String { + switch mediaType { + case "image/png": return "png" + case "image/jpeg": return "jpg" + case "image/gif": return "gif" + case "image/webp": return "webp" + default: return "img" + } + } +} diff --git a/TablePro/Core/AI/Images/ChatImageConverter.swift b/TablePro/Core/AI/Images/ChatImageConverter.swift new file mode 100644 index 000000000..e072e2f65 --- /dev/null +++ b/TablePro/Core/AI/Images/ChatImageConverter.swift @@ -0,0 +1,176 @@ +// +// ChatImageConverter.swift +// TablePro +// + +import AppKit +import CoreServices +import Foundation +import ImageIO +import os +import UniformTypeIdentifiers + +enum ChatImageConverterError: Error, LocalizedError { + case unsupportedFormat + case decodingFailed + case encodingFailed + + var errorDescription: String? { + switch self { + case .unsupportedFormat: return String(localized: "Unsupported image format") + case .decodingFailed: return String(localized: "Could not decode image") + case .encodingFailed: return String(localized: "Could not encode image") + } + } +} + +enum ChatImageConverter { + private static let logger = Logger(subsystem: "com.TablePro", category: "ChatImageConverter") + + static let maxLongEdgePixels: CGFloat = 2_000 + static let jpegQuality: CGFloat = 0.92 + + static func convert(fileURL url: URL) async throws -> ChatImageInput { + if url.scheme == "http" || url.scheme == "https" { + let mediaType = mediaType(forExtension: url.pathExtension) + return ChatImageInput(source: .remoteURL(url, mediaType: mediaType)) + } + let data = try Data(contentsOf: url) + return try await convert(data: data, sourceUTI: nil) + } + + static func convert(itemProvider: NSItemProvider) async throws -> ChatImageInput { + if itemProvider.canLoadObject(ofClass: NSImage.self) { + let image: NSImage = try await loadObject(itemProvider: itemProvider) + return try encode(nsImage: image) + } + if let typeIdentifier = itemProvider.registeredTypeIdentifiers.first(where: { UTType($0)?.conforms(to: .image) ?? false }) { + let data = try await loadData(itemProvider: itemProvider, typeIdentifier: typeIdentifier) + return try await convert(data: data, sourceUTI: typeIdentifier) + } + throw ChatImageConverterError.unsupportedFormat + } + + static func convert(data: Data, sourceUTI: String?) async throws -> ChatImageInput { + try await Task.detached(priority: .userInitiated) { + try encodeData(data, sourceUTI: sourceUTI) + }.value + } + + private static func encodeData(_ data: Data, sourceUTI: String?) throws -> ChatImageInput { + guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { + throw ChatImageConverterError.decodingFailed + } + let detectedType = (CGImageSourceGetType(source) as String?) ?? sourceUTI ?? UTType.image.identifier + let useJPEG = preferJPEG(forUTI: detectedType) + return try encode(source: source, useJPEG: useJPEG) + } + + private static func encode(nsImage: NSImage) throws -> ChatImageInput { + guard let tiff = nsImage.tiffRepresentation, + let source = CGImageSourceCreateWithData(tiff as CFData, nil) else { + throw ChatImageConverterError.encodingFailed + } + return try encode(source: source, useJPEG: false) + } + + private static func encode(source: CGImageSource, useJPEG: Bool) throws -> ChatImageInput { + guard let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil) else { + throw ChatImageConverterError.decodingFailed + } + let scaledImage = downscaleIfNeeded(cgImage) + let targetType: CFString = useJPEG ? UTType.jpeg.identifier as CFString : UTType.png.identifier as CFString + let mediaType = useJPEG ? "image/jpeg" : "image/png" + let output = NSMutableData() + guard let destination = CGImageDestinationCreateWithData(output, targetType, 1, nil) else { + throw ChatImageConverterError.encodingFailed + } + var properties: [CFString: Any] = [:] + if useJPEG { + properties[kCGImageDestinationLossyCompressionQuality] = jpegQuality + } + CGImageDestinationAddImage(destination, scaledImage, properties as CFDictionary) + guard CGImageDestinationFinalize(destination) else { + throw ChatImageConverterError.encodingFailed + } + let data = output as Data + let filename = AIImageCache.shared.store(data: data, mediaType: mediaType) + return ChatImageInput(source: .cacheFile(filename: filename, mediaType: mediaType)) + } + + private static func downscaleIfNeeded(_ image: CGImage) -> CGImage { + let width = CGFloat(image.width) + let height = CGFloat(image.height) + let longEdge = max(width, height) + guard longEdge > maxLongEdgePixels else { return image } + let scale = maxLongEdgePixels / longEdge + let newWidth = Int(width * scale) + let newHeight = Int(height * scale) + let colorSpace = image.colorSpace ?? CGColorSpaceCreateDeviceRGB() + let bitsPerComponent = 8 + let bytesPerRow = newWidth * 4 + guard let context = CGContext( + data: nil, + width: newWidth, + height: newHeight, + bitsPerComponent: bitsPerComponent, + bytesPerRow: bytesPerRow, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { return image } + context.interpolationQuality = .high + context.draw(image, in: CGRect(x: 0, y: 0, width: newWidth, height: newHeight)) + return context.makeImage() ?? image + } + + private static func preferJPEG(forUTI uti: String) -> Bool { + guard let type = UTType(uti) else { return false } + if type.conforms(to: .png) { return false } + if type.conforms(to: .jpeg) { return true } + if type.conforms(to: .heic) || type.conforms(to: .heif) { return true } + if type.conforms(to: .tiff) || type.conforms(to: .bmp) { return false } + return false + } + + private static func mediaType(forExtension ext: String) -> String { + switch ext.lowercased() { + case "png": return "image/png" + case "jpg", "jpeg": return "image/jpeg" + case "gif": return "image/gif" + case "webp": return "image/webp" + default: return "image/png" + } + } + + private static func loadObject(itemProvider: NSItemProvider) async throws -> T { + try await withCheckedThrowingContinuation { continuation in + itemProvider.loadObject(ofClass: T.self) { object, error in + if let error { + continuation.resume(throwing: error) + return + } + guard let typed = object as? T else { + continuation.resume(throwing: ChatImageConverterError.decodingFailed) + return + } + continuation.resume(returning: typed) + } + } + } + + private static func loadData(itemProvider: NSItemProvider, typeIdentifier: String) async throws -> Data { + try await withCheckedThrowingContinuation { continuation in + itemProvider.loadDataRepresentation(forTypeIdentifier: typeIdentifier) { data, error in + if let error { + continuation.resume(throwing: error) + return + } + guard let data else { + continuation.resume(throwing: ChatImageConverterError.decodingFailed) + return + } + continuation.resume(returning: data) + } + } + } +} diff --git a/TablePro/Core/AI/OpenAICompatibleProvider.swift b/TablePro/Core/AI/OpenAICompatibleProvider.swift index 304a7f354..b578a744c 100644 --- a/TablePro/Core/AI/OpenAICompatibleProvider.swift +++ b/TablePro/Core/AI/OpenAICompatibleProvider.swift @@ -360,6 +360,10 @@ final class OpenAICompatibleProvider: ChatTransport { if case .toolResult(let resultBlock) = block.kind { return resultBlock } return nil } + let imageBlocks = turn.blocks.compactMap { block -> ChatImageInput? in + if case .image(let input) = block.kind { return input } + return nil + } let textContent = turn.plainText if turn.role == .assistant, !toolUseBlocks.isEmpty { @@ -399,6 +403,23 @@ final class OpenAICompatibleProvider: ChatTransport { return messages } + if turn.role == .user, !imageBlocks.isEmpty { + var parts: [[String: Any]] = [] + if !textContent.isEmpty { + parts.append(["type": "text", "text": textContent]) + } + for image in imageBlocks { + if let part = chatCompletionsImagePart(image) { + parts.append(part) + } + } + guard !parts.isEmpty else { return [] } + return [[ + "role": "user", + "content": parts + ]] + } + guard !textContent.isEmpty else { return [] } return [[ "role": turn.role.rawValue, @@ -406,6 +427,17 @@ final class OpenAICompatibleProvider: ChatTransport { ]] } + private func chatCompletionsImagePart(_ input: ChatImageInput) -> [String: Any]? { + guard let url = input.imageURLString() else { return nil } + return [ + "type": "image_url", + "image_url": [ + "url": url, + "detail": input.detailHint.rawValue + ] as [String: Any] + ] + } + func encodeTool(_ tool: ChatToolSpec) throws -> [String: Any] { let parameters = try tool.inputSchema.jsonObject() return [ diff --git a/TablePro/Core/AI/OpenAIResponsesProvider.swift b/TablePro/Core/AI/OpenAIResponsesProvider.swift new file mode 100644 index 000000000..11ed721cd --- /dev/null +++ b/TablePro/Core/AI/OpenAIResponsesProvider.swift @@ -0,0 +1,388 @@ +// +// OpenAIResponsesProvider.swift +// TablePro +// + +import Foundation +import os + +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 session: URLSession + + init( + endpoint: String, + apiKey: String?, + model: String = "", + maxOutputTokens: Int? = nil, + 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.session = session + } + + func streamChat( + turns: [ChatTurnWire], + options: ChatTransportOptions + ) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { + do { + let request = try buildRequest(turns: turns, options: options, stream: true) + let (bytes, response) = try await session.bytes(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw AIProviderError.networkError("Invalid response") + } + guard httpResponse.statusCode == 200 else { + let errorBody = try await collectErrorBody(from: bytes) + throw AIProviderError.mapHTTPError( + statusCode: httpResponse.statusCode, + body: errorBody + ) + } + + var state = ResponsesStreamState() + for try await line in bytes.lines { + if Task.isCancelled { break } + guard let json = Self.decodeStreamLine(line) else { continue } + let events = try Self.parseEvent(json, state: &state) + for event in events { continuation.yield(event) } + } + if let usage = state.finalUsageEvent() { + continuation.yield(usage) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { _ in + task.cancel() + } + } + } + + func fetchAvailableModels() async throws -> [String] { + guard let url = URL(string: "\(endpoint)/v1/models") else { + throw AIProviderError.invalidEndpoint(endpoint) + } + var request = URLRequest(url: url) + request.timeoutInterval = AIProvider.modelListTimeout + if let apiKey, !apiKey.isEmpty { + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + } + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: request) + } catch { + Self.logger.warning("OpenAI Responses model fetch failed: \(error.localizedDescription, privacy: .public)") + throw AIProviderError.networkError("Failed to fetch models") + } + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let modelsArray = json["data"] as? [[String: Any]] + else { + throw AIProviderError.networkError("Failed to fetch models") + } + return modelsArray.compactMap { $0["id"] as? String }.sorted() + } + + func testConnection() async throws -> Bool { + let testModel = model.isEmpty ? "gpt-5.5" : 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) + 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 + ) throws -> URLRequest { + guard let url = URL(string: "\(endpoint)/v1/responses") else { + throw AIProviderError.invalidEndpoint(endpoint) + } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + if let apiKey, !apiKey.isEmpty { + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + } + + var body: [String: Any] = [ + "model": options.model, + "input": try Self.encodeInput(turns: turns), + "store": false, + "stream": stream + ] + + if let systemPrompt = options.systemPrompt, !systemPrompt.isEmpty { + body["instructions"] = systemPrompt + } + + let resolvedMaxTokens = options.maxOutputTokens + ?? maxOutputTokens + ?? options.reasoningEffort?.autoScaledMaxOutputTokens + if let resolvedMaxTokens { + body["max_output_tokens"] = resolvedMaxTokens + } + + if let effort = options.reasoningEffort { + body["reasoning"] = ["effort": effort.openAIWireValue] + body["include"] = ["reasoning.encrypted_content"] + } + + if !options.tools.isEmpty { + body["tools"] = try options.tools.map(Self.encodeToolSpec(_:)) + } + + request.httpBody = try JSONSerialization.data(withJSONObject: body) + return request + } + + static func encodeInput(turns: [ChatTurnWire]) throws -> [[String: Any]] { + var items: [[String: Any]] = [] + for turn in turns where turn.role != .system { + items.append(contentsOf: try encodeTurn(turn)) + } + return items + } + + static func encodeTurn(_ turn: ChatTurnWire) throws -> [[String: Any]] { + var items: [[String: Any]] = [] + var messageParts: [[String: Any]] = [] + + if turn.role == .assistant { + for block in turn.blocks { + switch block.kind { + case .reasoning(let reasoning): + guard let opaque = reasoning.opaque, + opaque.kind == .openAIEncrypted, + !opaque.itemID.isEmpty else { continue } + flushAssistantMessage(parts: &messageParts, into: &items) + items.append([ + "type": "reasoning", + "id": opaque.itemID, + "encrypted_content": opaque.value + ]) + case .text(let text): + guard !text.isEmpty else { continue } + messageParts.append(["type": "output_text", "text": text]) + case .toolUse(let useBlock): + flushAssistantMessage(parts: &messageParts, into: &items) + items.append([ + "type": "function_call", + "call_id": useBlock.id, + "name": useBlock.name, + "arguments": useBlock.input.jsonString() + ]) + case .toolResult, .attachment, .image: + continue + } + } + flushAssistantMessage(parts: &messageParts, into: &items) + return items + } + + if turn.role == .user { + for block in turn.blocks { + if case .toolResult(let resultBlock) = block.kind { + items.append([ + "type": "function_call_output", + "call_id": resultBlock.toolUseId, + "output": resultBlock.content + ]) + } + } + + var userParts: [[String: Any]] = [] + for block in turn.blocks { + switch block.kind { + case .text(let text): + guard !text.isEmpty else { continue } + userParts.append(["type": "input_text", "text": text]) + case .image(let input): + if let part = inputImagePart(input) { + userParts.append(part) + } + case .toolUse, .toolResult, .attachment, .reasoning: + continue + } + } + if !userParts.isEmpty { + items.append([ + "type": "message", + "role": "user", + "content": userParts + ]) + } + return items + } + + return [] + } + + static func encodeToolSpec(_ spec: ChatToolSpec) throws -> [String: Any] { + var encoded: [String: Any] = [ + "type": "function", + "name": spec.name, + "description": spec.description, + "parameters": try spec.inputSchema.jsonObject() + ] + encoded["strict"] = spec.strict + return encoded + } + + private static func inputImagePart(_ input: ChatImageInput) -> [String: Any]? { + guard let imageURL = input.imageURLString() else { return nil } + return [ + "type": "input_image", + "image_url": imageURL, + "detail": input.detailHint.rawValue + ] + } + + private static func flushAssistantMessage(parts: inout [[String: Any]], into items: inout [[String: Any]]) { + guard !parts.isEmpty else { return } + items.append([ + "type": "message", + "role": "assistant", + "content": parts + ]) + parts = [] + } + + static func decodeStreamLine(_ line: String) -> [String: Any]? { + guard line.hasPrefix("data: ") else { return nil } + let payload = String(line.dropFirst(6)) + guard payload != "[DONE]", + let data = payload.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return nil } + return json + } + + static func parseEvent( + _ json: [String: Any], + state: inout ResponsesStreamState + ) throws -> [ChatStreamEvent] { + guard let type = json["type"] as? String else { return [] } + switch type { + case "response.output_text.delta": + if let delta = json["delta"] as? String, !delta.isEmpty { + return [.textDelta(delta)] + } + return [] + case "response.reasoning_summary_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)] + case "response.output_item.added": + guard let item = json["item"] as? [String: Any], + let itemType = item["type"] as? String else { return [] } + switch itemType { + case "reasoning": + guard let itemID = item["id"] as? String else { return [] } + state.openReasoningItemIDs.insert(itemID) + return [.reasoningStart(id: itemID)] + case "function_call": + guard let callID = item["call_id"] as? String, + let name = item["name"] as? String else { return [] } + state.openFunctionCallIDs.insert(callID) + return [.toolUseStart(id: callID, name: name)] + default: + return [] + } + case "response.output_item.done": + guard let item = json["item"] as? [String: Any], + let itemType = item["type"] as? String else { return [] } + switch itemType { + case "reasoning": + guard let itemID = item["id"] as? String, + state.openReasoningItemIDs.remove(itemID) != nil else { return [] } + let encrypted = item["encrypted_content"] as? String + let opaque = encrypted.map { + ReasoningOpaque( + kind: .openAIEncrypted, + itemID: itemID, + value: $0, + blockType: "reasoning" + ) + } + return [.reasoningEnd(id: itemID, opaque: opaque)] + case "function_call": + guard let callID = item["call_id"] as? String, + state.openFunctionCallIDs.remove(callID) != nil else { return [] } + return [.toolUseEnd(id: callID)] + default: + return [] + } + case "response.function_call_arguments.delta": + guard let callID = json["call_id"] as? String ?? json["item_id"] as? String, + let delta = json["delta"] as? String, !delta.isEmpty else { return [] } + return [.toolUseDelta(id: callID, inputJSONDelta: delta)] + case "response.refusal.delta": + if let delta = json["delta"] as? String, !delta.isEmpty { + return [.textDelta(delta)] + } + return [] + case "response.completed": + if let responseObj = json["response"] as? [String: Any], + let usage = responseObj["usage"] as? [String: Any] { + let input = usage["input_tokens"] as? Int ?? 0 + let output = usage["output_tokens"] as? Int ?? 0 + state.inputTokens = input + state.outputTokens = output + } + return [] + case "response.failed": + if let responseObj = json["response"] as? [String: Any], + let errorObj = responseObj["error"] as? [String: Any], + let message = errorObj["message"] as? String { + throw AIProviderError.streamingFailed(message) + } + throw AIProviderError.streamingFailed(String(localized: "Response failed")) + case "error": + if let message = json["message"] as? String { + throw AIProviderError.streamingFailed(message) + } + return [] + default: + return [] + } + } +} + +struct ResponsesStreamState { + var inputTokens: Int = 0 + var outputTokens: Int = 0 + var openReasoningItemIDs: Set = [] + var openFunctionCallIDs: Set = [] + + func finalUsageEvent() -> ChatStreamEvent? { + guard inputTokens > 0 || outputTokens > 0 else { return nil } + return .usage(AITokenUsage(inputTokens: inputTokens, outputTokens: outputTokens)) + } +} diff --git a/TablePro/Core/AI/Registry/AIProviderDescriptor.swift b/TablePro/Core/AI/Registry/AIProviderDescriptor.swift index 99f01786f..198402f4a 100644 --- a/TablePro/Core/AI/Registry/AIProviderDescriptor.swift +++ b/TablePro/Core/AI/Registry/AIProviderDescriptor.swift @@ -2,21 +2,38 @@ // AIProviderDescriptor.swift // TablePro // -// Descriptor for an AI provider type, including capabilities and factory closure. -// import Foundation -/// Capabilities supported by an AI provider struct AIProviderCapabilities: OptionSet, Sendable { let rawValue: UInt8 static let chat = AIProviderCapabilities(rawValue: 1 << 0) static let inline = AIProviderCapabilities(rawValue: 1 << 1) static let models = AIProviderCapabilities(rawValue: 1 << 2) + static let reasoning = AIProviderCapabilities(rawValue: 1 << 3) + static let images = AIProviderCapabilities(rawValue: 1 << 4) +} + +struct CuratedModel: Sendable, Identifiable, Equatable { + let id: String + let displayName: String + let supportedEffortLevels: [ReasoningEffort] + let defaultEffort: ReasoningEffort? + + init( + id: String, + displayName: String, + supportedEffortLevels: [ReasoningEffort] = [], + defaultEffort: ReasoningEffort? = nil + ) { + self.id = id + self.displayName = displayName + self.supportedEffortLevels = supportedEffortLevels + self.defaultEffort = defaultEffort + } } -/// Describes an AI provider type for the registry struct AIProviderDescriptor: Sendable { let typeID: String let displayName: String @@ -24,5 +41,41 @@ struct AIProviderDescriptor: Sendable { let requiresAPIKey: Bool let capabilities: AIProviderCapabilities let symbolName: String + let curatedModels: [CuratedModel] let makeProvider: @Sendable (AIProviderConfig, String?) -> ChatTransport + + var supportsReasoning: Bool { capabilities.contains(.reasoning) } + var supportsImages: Bool { capabilities.contains(.images) } + + func curatedModel(forID id: String) -> CuratedModel? { + curatedModels.first(where: { $0.id == id }) + } + + func supportedEffortLevels(forModelID id: String) -> [ReasoningEffort] { + guard supportsReasoning else { return [] } + if let curated = curatedModel(forID: id), !curated.supportedEffortLevels.isEmpty { + return curated.supportedEffortLevels + } + return ReasoningEffort.allCases + } + + init( + typeID: String, + displayName: String, + defaultEndpoint: String, + requiresAPIKey: Bool, + capabilities: AIProviderCapabilities, + symbolName: String, + curatedModels: [CuratedModel] = [], + makeProvider: @escaping @Sendable (AIProviderConfig, String?) -> ChatTransport + ) { + self.typeID = typeID + self.displayName = displayName + self.defaultEndpoint = defaultEndpoint + self.requiresAPIKey = requiresAPIKey + self.capabilities = capabilities + self.symbolName = symbolName + self.curatedModels = curatedModels + self.makeProvider = makeProvider + } } diff --git a/TablePro/Core/AI/Registry/AIProviderRegistration.swift b/TablePro/Core/AI/Registry/AIProviderRegistration.swift index 57f401ecb..55a16d3fe 100644 --- a/TablePro/Core/AI/Registry/AIProviderRegistration.swift +++ b/TablePro/Core/AI/Registry/AIProviderRegistration.swift @@ -2,8 +2,6 @@ // AIProviderRegistration.swift // TablePro // -// Registers all built-in AI provider descriptors at app launch. -// import Foundation @@ -16,14 +14,18 @@ enum AIProviderRegistration { displayName: "Claude", defaultEndpoint: "https://api.anthropic.com", requiresAPIKey: true, - capabilities: [.chat, .models], + capabilities: [.chat, .models, .reasoning, .images], symbolName: "brain", + curatedModels: claudeCuratedModels, makeProvider: { config, apiKey in AnthropicProvider( endpoint: config.endpoint, apiKey: apiKey ?? "", model: config.model, - maxOutputTokens: config.maxOutputTokens ?? 4_096 + maxOutputTokens: config.maxOutputTokens + ?? config.reasoningEffort?.autoScaledMaxOutputTokens + ?? 4_096, + reasoningEffort: config.reasoningEffort ) } )) @@ -44,8 +46,25 @@ enum AIProviderRegistration { } )) - // OpenAI, OpenRouter, Ollama, Custom all use OpenAICompatibleProvider - for type in [AIProviderType.openAI, .openRouter, .ollama, .custom] { + registry.register(AIProviderDescriptor( + typeID: AIProviderType.openAI.rawValue, + displayName: AIProviderType.openAI.displayName, + defaultEndpoint: AIProviderType.openAI.defaultEndpoint, + requiresAPIKey: true, + capabilities: [.chat, .models, .reasoning, .images], + symbolName: iconForType(.openAI), + curatedModels: openAICuratedModels, + makeProvider: { config, apiKey in + OpenAIResponsesProvider( + endpoint: config.endpoint, + apiKey: apiKey, + model: config.model, + maxOutputTokens: config.maxOutputTokens + ) + } + )) + + for type in [AIProviderType.openRouter, .ollama, .custom] { registry.register(AIProviderDescriptor( typeID: type.rawValue, displayName: type.displayName, @@ -76,13 +95,55 @@ enum AIProviderRegistration { )) } + private static let openAICuratedModels: [CuratedModel] = [ + CuratedModel( + id: "gpt-5.5", + displayName: "GPT-5.5", + supportedEffortLevels: ReasoningEffort.allCases, + defaultEffort: .medium + ), + CuratedModel( + id: "gpt-5-codex", + displayName: "GPT-5 Codex", + supportedEffortLevels: [.low, .medium, .high], + defaultEffort: .medium + ), + CuratedModel( + id: "gpt-5.3-codex", + displayName: "GPT-5.3 Codex", + supportedEffortLevels: [.low, .medium, .high, .xhigh], + defaultEffort: .medium + ), + CuratedModel( + id: "gpt-5.4-mini", + displayName: "GPT-5.4 Mini", + supportedEffortLevels: ReasoningEffort.allCases, + defaultEffort: .medium + ) + ] + + private static let claudeCuratedModels: [CuratedModel] = [ + CuratedModel( + id: "claude-opus-4-7-20260101", + displayName: "Claude Opus 4.7", + supportedEffortLevels: [.low, .medium, .high, .xhigh], + defaultEffort: .medium + ), + CuratedModel( + id: "claude-sonnet-4-6-20251101", + displayName: "Claude Sonnet 4.6", + supportedEffortLevels: [.low, .medium, .high, .xhigh], + defaultEffort: .medium + ), + CuratedModel( + id: "claude-haiku-4-5-20251001", + displayName: "Claude Haiku 4.5", + supportedEffortLevels: [.low, .medium, .high], + defaultEffort: .low + ) + ] + private static func iconForType(_ type: AIProviderType) -> String { - switch type { - case .openAI: return "cpu" - case .openRouter: return "globe" - case .ollama: return "desktopcomputer" - case .custom: return "server.rack" - default: return "questionmark.circle" - } + type.symbolName } } diff --git a/TablePro/Models/AI/AIModels.swift b/TablePro/Models/AI/AIModels.swift index 7463b05a4..48ae86fb6 100644 --- a/TablePro/Models/AI/AIModels.swift +++ b/TablePro/Models/AI/AIModels.swift @@ -2,8 +2,6 @@ // AIModels.swift // TablePro // -// AI feature data models — provider configuration, chat messages, and settings. -// import Foundation @@ -77,6 +75,7 @@ struct AIProviderConfig: Codable, Equatable, Identifiable, Sendable { var endpoint: String var maxOutputTokens: Int? var telemetryEnabled: Bool + var reasoningEffort: ReasoningEffort? init( id: UUID = UUID(), @@ -85,7 +84,8 @@ struct AIProviderConfig: Codable, Equatable, Identifiable, Sendable { model: String = "", endpoint: String = "", maxOutputTokens: Int? = nil, - telemetryEnabled: Bool = false + telemetryEnabled: Bool = false, + reasoningEffort: ReasoningEffort? = nil ) { self.id = id self.name = name @@ -94,6 +94,7 @@ struct AIProviderConfig: Codable, Equatable, Identifiable, Sendable { self.endpoint = endpoint.isEmpty ? type.defaultEndpoint : endpoint self.maxOutputTokens = maxOutputTokens self.telemetryEnabled = telemetryEnabled + self.reasoningEffort = reasoningEffort } init(from decoder: Decoder) throws { @@ -106,6 +107,7 @@ struct AIProviderConfig: Codable, Equatable, Identifiable, Sendable { endpoint = rawEndpoint.isEmpty ? type.defaultEndpoint : rawEndpoint maxOutputTokens = try container.decodeIfPresent(Int.self, forKey: .maxOutputTokens) telemetryEnabled = try container.decodeIfPresent(Bool.self, forKey: .telemetryEnabled) ?? false + reasoningEffort = try container.decodeIfPresent(ReasoningEffort.self, forKey: .reasoningEffort) } var displayName: String { diff --git a/TablePro/ViewModels/AIChatViewModel+Streaming.swift b/TablePro/ViewModels/AIChatViewModel+Streaming.swift index 53e5db4c7..9a3b3d1e7 100644 --- a/TablePro/ViewModels/AIChatViewModel+Streaming.swift +++ b/TablePro/ViewModels/AIChatViewModel+Streaming.swift @@ -234,7 +234,8 @@ extension AIChatViewModel { options: ChatTransportOptions( model: resolved.model, systemPrompt: systemPrompt, - tools: toolSpecs + tools: toolSpecs, + reasoningEffort: resolved.config.reasoningEffort ) ) @@ -243,6 +244,7 @@ extension AIChatViewModel { var toolUseOrder: [String] = [] var toolUseNames: [String: String] = [:] var toolUseInputs: [String: String] = [:] + var reasoningIDMap: [String: UUID] = [:] let flushInterval: ContinuousClock.Duration = .milliseconds(150) var lastFlushTime: ContinuousClock.Instant = .now @@ -277,6 +279,18 @@ extension AIChatViewModel { block: block, replyToken: replyToken, assistantID: assistantID, mode: chatMode ) + case .reasoningStart(let providerID): + if !pendingContent.isEmpty { + await self.flushPending(content: pendingContent, usage: pendingUsage, into: assistantID) + pendingContent = "" + pendingUsage = nil + lastFlushTime = .now + } + await self.startReasoning(providerID: providerID, assistantID: assistantID, idMap: &reasoningIDMap) + case .reasoningDelta(let providerID, let text): + await self.appendReasoning(providerID: providerID, text: text, assistantID: assistantID, idMap: &reasoningIDMap) + case .reasoningEnd(let providerID, let opaque): + await self.finalizeReasoning(providerID: providerID, opaque: opaque, assistantID: assistantID, idMap: &reasoningIDMap) } if ContinuousClock.now - lastFlushTime >= flushInterval { @@ -299,6 +313,42 @@ extension AIChatViewModel { ) } + private func startReasoning(providerID: String, assistantID: UUID, idMap: inout [String: UUID]) async { + let captured = idMap + let updated = await MainActor.run { [weak self] () -> [String: UUID] in + guard let self, + let idx = self.messages.firstIndex(where: { $0.id == assistantID }) else { return captured } + var localMap = captured + self.messages[idx].startReasoningBlock(providerBlockID: providerID, idMap: &localMap) + return localMap + } + idMap = updated + } + + private func appendReasoning(providerID: String, text: String, assistantID: UUID, idMap: inout [String: UUID]) async { + let captured = idMap + let updated = await MainActor.run { [weak self] () -> [String: UUID] in + guard let self, + let idx = self.messages.firstIndex(where: { $0.id == assistantID }) else { return captured } + var localMap = captured + _ = self.messages[idx].appendReasoningDelta(providerBlockID: providerID, text: text, idMap: &localMap) + return localMap + } + idMap = updated + } + + private func finalizeReasoning(providerID: String, opaque: ReasoningOpaque?, assistantID: UUID, idMap: inout [String: UUID]) async { + let captured = idMap + let updated = await MainActor.run { [weak self] () -> [String: UUID] in + guard let self, + let idx = self.messages.firstIndex(where: { $0.id == assistantID }) else { return captured } + var localMap = captured + self.messages[idx].finalizeReasoningBlock(providerBlockID: providerID, opaque: opaque, idMap: &localMap) + return localMap + } + idMap = updated + } + nonisolated static func buildSystemPrompt(_ promptContext: PromptContext?, mode: AIChatMode) -> String? { let schemaPrompt = promptContext.map { AISchemaContext.buildSystemPrompt( diff --git a/TablePro/ViewModels/AIChatViewModel.swift b/TablePro/ViewModels/AIChatViewModel.swift index 877988a83..32b01b87d 100644 --- a/TablePro/ViewModels/AIChatViewModel.swift +++ b/TablePro/ViewModels/AIChatViewModel.swift @@ -31,6 +31,7 @@ final class AIChatViewModel { var selectedModel: String? var availableModels: [UUID: [String]] = [:] var attachedContext: [ContextItem] = [] + var attachedImages: [ChatImageInput] = [] var savedQueries: [SQLFavorite] = [] var connection: DatabaseConnection? @@ -92,25 +93,56 @@ final class AIChatViewModel { func sendMessage() { let text = inputText.trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty else { return } + guard !text.isEmpty || !attachedImages.isEmpty else { return } if let parsed = SlashCommand.parse(text) { runSlashCommand(parsed.command, body: parsed.body) return } - var blocks: [ChatContentBlock] = [.text(text)] + var blocks: [ChatContentBlock] = [] + if !text.isEmpty { + blocks.append(.text(text)) + } blocks.append(contentsOf: attachedContext.map { .attachment($0) }) + blocks.append(contentsOf: attachedImages.map { .image($0) }) messages.append(ChatTurn(role: .user, blocks: blocks)) trimMessagesIfNeeded() inputText = "" attachedContext = [] + attachedImages = [] clearError() startStreaming() } + func attachImage(_ image: ChatImageInput) { + attachedImages.append(image) + } + + func reportImageAttachmentFailure(_ message: String) { + errorMessage = message + } + + func detachImage(at index: Int) { + guard attachedImages.indices.contains(index) else { return } + if case .cacheFile(let filename, _) = attachedImages[index].source { + AIImageCache.shared.delete(filename: filename) + } + attachedImages.remove(at: index) + } + + var activeProviderSupportsImages: Bool { + let settings = services.appSettings.ai + let configID = selectedProviderId ?? settings.activeProviderID + guard let configID, + let config = settings.providers.first(where: { $0.id == configID }), + let descriptor = AIProviderRegistry.shared.descriptor(for: config.type.rawValue) + else { return false } + return descriptor.supportsImages + } + func sendWithContext(prompt: String) { let userMessage = ChatTurn(role: .user, blocks: [.text(prompt)]) messages.append(userMessage) diff --git a/TablePro/Views/AIChat/AIChatComposerImageChip.swift b/TablePro/Views/AIChat/AIChatComposerImageChip.swift new file mode 100644 index 000000000..431a6003d --- /dev/null +++ b/TablePro/Views/AIChat/AIChatComposerImageChip.swift @@ -0,0 +1,34 @@ +// +// AIChatComposerImageChip.swift +// TablePro +// + +import SwiftUI + +struct AIChatComposerImageChip: View { + let input: ChatImageInput + let onRemove: () -> Void + + private static let chipSize: CGFloat = 56 + + var body: some View { + ZStack(alignment: .topTrailing) { + ChatImageThumbnailView(input: input) + .frame(width: Self.chipSize, height: Self.chipSize) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .strokeBorder(Color.secondary.opacity(0.2), lineWidth: 1) + ) + + Button(action: onRemove) { + Image(systemName: "xmark.circle.fill") + .font(.caption) + .foregroundStyle(.white, Color.black.opacity(0.6)) + } + .buttonStyle(.plain) + .padding(2) + .accessibilityLabel(String(localized: "Remove image")) + } + } +} diff --git a/TablePro/Views/AIChat/AIChatImageBlockView.swift b/TablePro/Views/AIChat/AIChatImageBlockView.swift new file mode 100644 index 000000000..d3880f771 --- /dev/null +++ b/TablePro/Views/AIChat/AIChatImageBlockView.swift @@ -0,0 +1,22 @@ +// +// AIChatImageBlockView.swift +// TablePro +// + +import SwiftUI + +struct AIChatImageBlockView: View { + let input: ChatImageInput + + private static let thumbnailSize: CGFloat = 96 + + var body: some View { + ChatImageThumbnailView(input: input) + .frame(width: Self.thumbnailSize, height: Self.thumbnailSize) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .strokeBorder(Color.secondary.opacity(0.2), lineWidth: 1) + ) + } +} diff --git a/TablePro/Views/AIChat/AIChatMessageView.swift b/TablePro/Views/AIChat/AIChatMessageView.swift index c57b626ff..76af07cea 100644 --- a/TablePro/Views/AIChat/AIChatMessageView.swift +++ b/TablePro/Views/AIChat/AIChatMessageView.swift @@ -138,6 +138,10 @@ struct AIChatMessageView: View { return true case .attachment: return false + case .reasoning(let reasoning): + return block.isStreaming || (reasoning.text?.isEmpty == false) + case .image: + return true } } if visibleBlocks.isEmpty { @@ -177,6 +181,12 @@ private struct AIChatBlockView: View { AIChatToolResultBlockView(block: resultBlock) case .attachment: EmptyView() + case .reasoning(let reasoning): + AIChatReasoningBlockView(block: reasoning, isStreaming: block.isStreaming) + .padding(.horizontal, 8) + case .image(let input): + AIChatImageBlockView(input: input) + .padding(.horizontal, 8) } } } diff --git a/TablePro/Views/AIChat/AIChatPanelView.swift b/TablePro/Views/AIChat/AIChatPanelView.swift index df6bf638d..b6dd81aea 100644 --- a/TablePro/Views/AIChat/AIChatPanelView.swift +++ b/TablePro/Views/AIChat/AIChatPanelView.swift @@ -213,6 +213,10 @@ struct AIChatPanelView: View { onRemove: { viewModel.detach($0) } ) + if !viewModel.attachedImages.isEmpty { + composerImageChipStrip + } + ChatComposerView( text: $viewModel.inputText, placeholder: String(localized: "Ask about your database..."), @@ -228,6 +232,15 @@ struct AIChatPanelView: View { }, onAttach: { item in viewModel.attach(item) + }, + acceptsImages: viewModel.activeProviderSupportsImages, + onAttachImages: { images in + for image in images { + viewModel.attachImage(image) + } + }, + onImageAttachmentFailed: { message in + viewModel.reportImageAttachmentFailure(message) } ) @@ -244,6 +257,19 @@ struct AIChatPanelView: View { } } + private var composerImageChipStrip: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(Array(viewModel.attachedImages.enumerated()), id: \.offset) { index, image in + AIChatComposerImageChip(input: image) { + viewModel.detachImage(at: index) + } + } + } + .padding(.horizontal, 2) + } + } + private var modeMenu: some View { let binding = Binding( get: { settingsManager.ai.chatMode }, @@ -503,8 +529,8 @@ struct AIChatPanelView: View { let hasUserContent = message.blocks.contains { block in switch block.kind { case .text(let value): return !value.isEmpty - case .attachment: return true - case .toolUse, .toolResult: return false + case .attachment, .image: return true + case .toolUse, .toolResult, .reasoning: return false } } if !hasUserContent { return false } diff --git a/TablePro/Views/AIChat/AIChatReasoningBlockView.swift b/TablePro/Views/AIChat/AIChatReasoningBlockView.swift new file mode 100644 index 000000000..7635c261e --- /dev/null +++ b/TablePro/Views/AIChat/AIChatReasoningBlockView.swift @@ -0,0 +1,67 @@ +// +// AIChatReasoningBlockView.swift +// TablePro +// + +import SwiftUI + +struct AIChatReasoningBlockView: View { + let block: ReasoningBlock + let isStreaming: Bool + + @State private var displayedText: String = "" + @State private var debounceTask: Task? + @State private var manualExpanded: Bool? + + private static let debounceInterval: Duration = .milliseconds(80) + + private var isExpanded: Bool { + manualExpanded ?? isStreaming + } + + private var expansionBinding: Binding { + Binding( + get: { isExpanded }, + set: { manualExpanded = $0 } + ) + } + + var body: some View { + DisclosureGroup(isExpanded: expansionBinding) { + if !displayedText.isEmpty { + Text(displayedText) + .font(.callout) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 4) + } + } label: { + Label( + isStreaming ? String(localized: "Reasoning…") : String(localized: "Reasoning"), + systemImage: isStreaming ? "ellipsis.bubble" : "lightbulb" + ) + .font(.caption) + .foregroundStyle(.secondary) + } + .onAppear { + displayedText = block.text ?? "" + } + .onChange(of: block.text ?? "") { _, newValue in + scheduleUpdate(to: newValue) + } + .onDisappear { + debounceTask?.cancel() + debounceTask = nil + } + } + + private func scheduleUpdate(to newValue: String) { + debounceTask?.cancel() + debounceTask = Task { @MainActor in + try? await Task.sleep(for: Self.debounceInterval) + guard !Task.isCancelled else { return } + displayedText = newValue + } + } +} diff --git a/TablePro/Views/AIChat/ChatComposerTextView.swift b/TablePro/Views/AIChat/ChatComposerTextView.swift index 5089f9571..8c8d5eb81 100644 --- a/TablePro/Views/AIChat/ChatComposerTextView.swift +++ b/TablePro/Views/AIChat/ChatComposerTextView.swift @@ -5,6 +5,7 @@ import AppKit import SwiftUI +import UniformTypeIdentifiers struct ChatComposerTextView: NSViewRepresentable { @Binding var text: String @@ -13,12 +14,14 @@ struct ChatComposerTextView: NSViewRepresentable { let minLines: Int let maxLines: Int let isCommittingMention: Bool + let acceptsImages: Bool let onTextChange: (String, Int) -> Void let onSubmit: () -> Void let onCommitMention: () -> Bool let onArrow: (Int) -> Bool let onTab: () -> Bool let onEscape: () -> Bool + let onPasteImageData: (Data, String) -> Void func makeNSView(context: Context) -> ChatComposerScrollView { let textView = ChatComposerNSTextView() @@ -39,6 +42,8 @@ struct ChatComposerTextView: NSViewRepresentable { textView.isVerticallyResizable = true textView.autoresizingMask = [.width] textView.placeholder = placeholder + textView.acceptsImagePaste = acceptsImages + textView.onPasteImageData = onPasteImageData let scrollView = ChatComposerScrollView() scrollView.documentView = textView @@ -200,6 +205,8 @@ final class ChatComposerNSTextView: NSTextView { var placeholderColor: NSColor = .placeholderTextColor var onFocusChange: ((Bool) -> Void)? var onSizeChange: (() -> Void)? + var acceptsImagePaste: Bool = false + var onPasteImageData: ((Data, String) -> Void)? override func becomeFirstResponder() -> Bool { let became = super.becomeFirstResponder() @@ -229,6 +236,30 @@ final class ChatComposerNSTextView: NSTextView { let origin = NSPoint(x: textContainerInset.width, y: textContainerInset.height) (placeholder as NSString).draw(at: origin, withAttributes: attributes) } + + override func paste(_ sender: Any?) { + guard acceptsImagePaste, let onPasteImageData else { + super.paste(sender) + return + } + let pasteboard = NSPasteboard.general + if let data = pasteboard.data(forType: .png) { + onPasteImageData(data, UTType.png.identifier) + return + } + if let data = pasteboard.data(forType: .tiff) { + onPasteImageData(data, UTType.tiff.identifier) + return + } + if let urls = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL], + let fileURL = urls.first(where: { (try? $0.resourceValues(forKeys: [.contentTypeKey]))?.contentType?.conforms(to: .image) ?? false }), + let data = try? Data(contentsOf: fileURL) { + let uti = (try? fileURL.resourceValues(forKeys: [.contentTypeKey]))?.contentType?.identifier ?? UTType.image.identifier + onPasteImageData(data, uti) + return + } + super.paste(sender) + } } final class ChatComposerScrollView: NSScrollView { diff --git a/TablePro/Views/AIChat/ChatComposerView.swift b/TablePro/Views/AIChat/ChatComposerView.swift index ddc6795c5..2fd94c75e 100644 --- a/TablePro/Views/AIChat/ChatComposerView.swift +++ b/TablePro/Views/AIChat/ChatComposerView.swift @@ -4,6 +4,7 @@ // import SwiftUI +import UniformTypeIdentifiers struct ChatComposerView: View { @Binding var text: String @@ -14,18 +15,49 @@ struct ChatComposerView: View { let onTextChange: (String, Int) -> Void let onSubmit: () -> Void let onAttach: (ContextItem) -> Void + let acceptsImages: Bool + let onAttachImages: ([ChatImageInput]) -> Void + let onImageAttachmentFailed: (String) -> Void @State private var isFocused: Bool = false @State private var isCommittingMention = false + @State private var isDropTargeted: Bool = false + + init( + text: Binding, + placeholder: String, + minLines: Int, + maxLines: Int, + mentionState: MentionPopoverState, + onTextChange: @escaping (String, Int) -> Void, + onSubmit: @escaping () -> Void, + onAttach: @escaping (ContextItem) -> Void, + acceptsImages: Bool = false, + onAttachImages: @escaping ([ChatImageInput]) -> Void = { _ in }, + onImageAttachmentFailed: @escaping (String) -> Void = { _ in } + ) { + self._text = text + self.placeholder = placeholder + self.minLines = minLines + self.maxLines = maxLines + self.mentionState = mentionState + self.onTextChange = onTextChange + self.onSubmit = onSubmit + self.onAttach = onAttach + self.acceptsImages = acceptsImages + self.onAttachImages = onAttachImages + self.onImageAttachmentFailed = onImageAttachmentFailed + } var body: some View { - ChatComposerTextView( + let composerCore = ChatComposerTextView( text: $text, isFocused: $isFocused, placeholder: placeholder, minLines: minLines, maxLines: maxLines, isCommittingMention: isCommittingMention, + acceptsImages: acceptsImages, onTextChange: { newText, caret in guard !isCommittingMention else { return } onTextChange(newText, caret) @@ -34,7 +66,8 @@ struct ChatComposerView: View { onCommitMention: { commitMentionIfVisible() }, onArrow: { delta in moveMention(by: delta) }, onTab: { commitMentionIfVisible() }, - onEscape: { dismissMention() } + onEscape: { dismissMention() }, + onPasteImageData: handlePastedImageData ) .fixedSize(horizontal: false, vertical: true) .background(composerBackground) @@ -48,6 +81,58 @@ struct ChatComposerView: View { onSelect: { commitMention(at: $0) } ) } + + return Group { + if acceptsImages { + composerCore + .onDrop( + of: [UTType.image, UTType.fileURL], + isTargeted: $isDropTargeted, + perform: handleDrop(providers:) + ) + .overlay { + if isDropTargeted { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .strokeBorder(Color.accentColor.opacity(0.6), lineWidth: 2) + .allowsHitTesting(false) + } + } + } else { + composerCore + } + } + } + + private func handleDrop(providers: [NSItemProvider]) -> Bool { + guard acceptsImages, !providers.isEmpty else { return false } + Task { @MainActor in + var collected: [ChatImageInput] = [] + var lastError: Error? + for provider in providers { + do { + collected.append(try await ChatImageConverter.convert(itemProvider: provider)) + } catch { + lastError = error + } + } + if !collected.isEmpty { + onAttachImages(collected) + } else if let lastError { + onImageAttachmentFailed(lastError.localizedDescription) + } + } + return true + } + + private func handlePastedImageData(_ data: Data, _ uti: String) { + Task { @MainActor in + do { + let input = try await ChatImageConverter.convert(data: data, sourceUTI: uti) + onAttachImages([input]) + } catch { + onImageAttachmentFailed(error.localizedDescription) + } + } } private var composerBackground: some View { diff --git a/TablePro/Views/AIChat/ChatImageThumbnailView.swift b/TablePro/Views/AIChat/ChatImageThumbnailView.swift new file mode 100644 index 000000000..cc6ee8c9f --- /dev/null +++ b/TablePro/Views/AIChat/ChatImageThumbnailView.swift @@ -0,0 +1,44 @@ +// +// ChatImageThumbnailView.swift +// TablePro +// + +import AppKit +import SwiftUI + +struct ChatImageThumbnailView: View { + let input: ChatImageInput + + var body: some View { + switch input.source { + case .cacheFile(let filename, _): + if let nsImage = AIImageCache.shared.loadImage(filename: filename) { + Image(nsImage: nsImage) + .resizable() + .scaledToFill() + } else { + placeholder + } + case .remoteURL(let url, _): + AsyncImage(url: url) { phase in + switch phase { + case .success(let image): + image.resizable().scaledToFill() + case .failure: + placeholder + case .empty: + ProgressView() + @unknown default: + placeholder + } + } + } + } + + private var placeholder: some View { + Image(systemName: "photo") + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.secondary.opacity(0.08)) + } +} diff --git a/TablePro/Views/Settings/AIProviderDetailSheet.swift b/TablePro/Views/Settings/AIProviderDetailSheet.swift index d2e65bb2e..8f4bf16c1 100644 --- a/TablePro/Views/Settings/AIProviderDetailSheet.swift +++ b/TablePro/Views/Settings/AIProviderDetailSheet.swift @@ -282,61 +282,142 @@ struct AIProviderDetailSheet: View { // MARK: - Model + private var descriptor: AIProviderDescriptor? { + AIProviderRegistry.shared.descriptor(for: draft.type.rawValue) + } + + private var curatedModels: [CuratedModel] { + descriptor?.curatedModels ?? [] + } + + private var effortLevelsForCurrentModel: [ReasoningEffort] { + descriptor?.supportedEffortLevels(forModelID: draft.model) ?? [] + } + + private var showsReasoningPicker: Bool { + guard descriptor?.supportsReasoning == true else { return false } + return !effortLevelsForCurrentModel.isEmpty + } + + private var isCustomModel: Bool { + !curatedModels.contains(where: { $0.id == draft.model }) + } + private var modelSection: some View { Section { - HStack { - Text("Model") - Spacer() - modelControl + modelPicker + if isCustomModel { + TextField(String(localized: "Model ID"), text: $draft.model) + .textFieldStyle(.roundedBorder) } - if let modelFetchError { - HStack { - Text(modelFetchError) - .font(.caption) - .foregroundStyle(Color(nsColor: .systemRed)) - .lineLimit(2) - Spacer() - Button(String(localized: "Reload")) { - fetchModels() - } - .buttonStyle(.borderless) - .controlSize(.small) - } + if showsReasoningPicker { + reasoningPicker } + modelFetchStatus } header: { Text("Model") } } - @ViewBuilder - private var modelControl: some View { - HStack(spacing: 8) { - TextField(String(localized: "Model name"), text: $draft.model) - .frame(width: 260) + private var modelPicker: some View { + Picker(String(localized: "Model"), selection: modelSelectionBinding) { + if !curatedModels.isEmpty { + Section { + ForEach(curatedModels) { model in + Text(model.displayName).tag(ModelSelection.curated(model.id)) + } + } + } + let fetchedFiltered = fetchedModels.filter { id in + !curatedModels.contains(where: { $0.id == id }) + } + if !fetchedFiltered.isEmpty { + Section { + ForEach(fetchedFiltered, id: \.self) { id in + Text(id).tag(ModelSelection.fetched(id)) + } + } + } + Text(String(localized: "Other…")).tag(ModelSelection.custom) + } + .pickerStyle(.menu) + } - if isFetchingModels { + private enum ModelSelection: Hashable { + case curated(String) + case fetched(String) + case custom + } + + private var modelSelectionBinding: Binding { + Binding( + get: { + if curatedModels.contains(where: { $0.id == draft.model }) { + return .curated(draft.model) + } + if fetchedModels.contains(draft.model) { + return .fetched(draft.model) + } + return .custom + }, + set: { newValue in + switch newValue { + case .curated(let id): + draft.model = id + if let curated = curatedModels.first(where: { $0.id == id }) { + if let defaultEffort = curated.defaultEffort, draft.reasoningEffort == nil { + draft.reasoningEffort = defaultEffort + } + let supported = Set(curated.supportedEffortLevels) + if let currentEffort = draft.reasoningEffort, !supported.contains(currentEffort) { + draft.reasoningEffort = curated.defaultEffort + } + } + case .fetched(let id): + draft.model = id + case .custom: + if curatedModels.contains(where: { $0.id == draft.model }) || fetchedModels.contains(draft.model) { + draft.model = "" + } + } + } + ) + } + + private var reasoningPicker: some View { + Picker(String(localized: "Reasoning"), selection: $draft.reasoningEffort) { + Text(String(localized: "Off")).tag(ReasoningEffort?.none) + ForEach(effortLevelsForCurrentModel) { effort in + Text(effort.displayName).tag(Optional(effort)) + } + } + .pickerStyle(.menu) + } + + @ViewBuilder + private var modelFetchStatus: some View { + if isFetchingModels { + HStack(spacing: 6) { ProgressView().controlSize(.small) - } else if fetchedModels.isEmpty { + Text(String(localized: "Fetching models…")) + .font(.caption) + .foregroundStyle(.secondary) + } + } + if let modelFetchError { + HStack { + Text(modelFetchError) + .font(.caption) + .foregroundStyle(Color(nsColor: .systemRed)) + .lineLimit(2) + Spacer() Button(String(localized: "Reload")) { fetchModels() } .buttonStyle(.borderless) .controlSize(.small) - } else { - Menu { - ForEach(fetchedModels, id: \.self) { model in - Button(model) { - draft.model = model - } - } - } label: { - Image(systemName: "chevron.down.circle") - } - .menuStyle(.borderlessButton) - .help(String(localized: "Choose a fetched model")) } } - .fixedSize() } // MARK: - Advanced diff --git a/TableProTests/Core/AI/OpenAIResponsesProviderEncodingTests.swift b/TableProTests/Core/AI/OpenAIResponsesProviderEncodingTests.swift new file mode 100644 index 000000000..9b046d4ca --- /dev/null +++ b/TableProTests/Core/AI/OpenAIResponsesProviderEncodingTests.swift @@ -0,0 +1,118 @@ +// +// OpenAIResponsesProviderEncodingTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("OpenAIResponsesProvider request encoding") +struct OpenAIResponsesProviderEncodingTests { + @Test("encodeToolSpec emits flat shape with strict at top level") + func toolSpecShape() throws { + let spec = ChatToolSpec( + name: "ping", + description: "Ping the database", + inputSchema: ChatToolSchemaBuilder.object(properties: [ + "host": ChatToolSchemaBuilder.string(description: "host name") + ]) + ) + let encoded = try OpenAIResponsesProvider.encodeToolSpec(spec) + #expect(encoded["type"] as? String == "function") + #expect(encoded["name"] as? String == "ping") + #expect(encoded["description"] as? String == "Ping the database") + #expect(encoded["strict"] as? Bool == true) + #expect(encoded["parameters"] != nil) + #expect(encoded["function"] == nil, "Responses API tool shape must be flat, not nested") + } + + @Test("user text turn encodes as input_text message") + func userTextTurn() throws { + let turn = ChatTurnWire(role: .user, blocks: [.text("hello")]) + let items = try OpenAIResponsesProvider.encodeTurn(turn) + #expect(items.count == 1) + let message = items[0] + #expect(message["type"] as? String == "message") + #expect(message["role"] as? String == "user") + let content = message["content"] as? [[String: Any]] ?? [] + #expect(content.first?["type"] as? String == "input_text") + #expect(content.first?["text"] as? String == "hello") + } + + @Test("assistant turn with reasoning + tool_use emits reasoning item before function_call") + func reasoningRoundTripOrdering() throws { + let opaque = ReasoningOpaque( + kind: .openAIEncrypted, + itemID: "rs_real_abc", + value: "BLOB=", + blockType: "reasoning" + ) + let reasoning = ReasoningBlock(text: "I should call ping", opaque: opaque) + let toolUse = ToolUseBlock(id: "call_1", name: "ping", input: .object([:])) + let turn = ChatTurnWire(role: .assistant, blocks: [ + .reasoning(reasoning), + .toolUse(toolUse) + ]) + let items = try OpenAIResponsesProvider.encodeTurn(turn) + #expect(items.count == 2) + #expect(items[0]["type"] as? String == "reasoning", "Reasoning item must come before its function_call") + #expect(items[0]["id"] as? String == "rs_real_abc", "Reasoning item id must round-trip from server") + #expect(items[0]["encrypted_content"] as? String == "BLOB=") + #expect(items[1]["type"] as? String == "function_call") + #expect(items[1]["call_id"] as? String == "call_1") + #expect(items[1]["name"] as? String == "ping") + } + + @Test("reasoning + text + tool_use flushes text into message item between reasoning and function_call") + func reasoningTextToolUseOrdering() throws { + let opaque = ReasoningOpaque( + kind: .openAIEncrypted, + itemID: "rs_1", + value: "ENC=", + blockType: "reasoning" + ) + let turn = ChatTurnWire(role: .assistant, blocks: [ + .text("preface text"), + .reasoning(ReasoningBlock(opaque: opaque)), + .toolUse(ToolUseBlock(id: "call_1", name: "ping", input: .object([:]))) + ]) + let items = try OpenAIResponsesProvider.encodeTurn(turn) + #expect(items.count == 3) + #expect(items[0]["type"] as? String == "message", "Text emitted before reasoning must flush first") + #expect(items[1]["type"] as? String == "reasoning") + #expect(items[2]["type"] as? String == "function_call") + } + + @Test("user turn with tool_result emits function_call_output with matching call_id") + func toolResultEncoding() throws { + let result = ToolResultBlock(toolUseId: "call_1", content: "ok") + let turn = ChatTurnWire(role: .user, blocks: [.toolResult(result)]) + let items = try OpenAIResponsesProvider.encodeTurn(turn) + #expect(items.count == 1) + #expect(items[0]["type"] as? String == "function_call_output") + #expect(items[0]["call_id"] as? String == "call_1") + #expect(items[0]["output"] as? String == "ok") + } + + @Test("remote URL image encodes as input_image with sibling detail") + func remoteImageEncoding() throws { + guard let url = URL(string: "https://example.com/cat.png") else { + Issue.record("invalid url fixture") + return + } + let image = ChatImageInput( + source: .remoteURL(url, mediaType: "image/png"), + detailHint: .high + ) + let turn = ChatTurnWire(role: .user, blocks: [.text("look"), .image(image)]) + let items = try OpenAIResponsesProvider.encodeTurn(turn) + #expect(items.count == 1) + let content = items[0]["content"] as? [[String: Any]] ?? [] + let imagePart = content.first(where: { ($0["type"] as? String) == "input_image" }) + #expect(imagePart != nil) + #expect(imagePart?["image_url"] as? String == "https://example.com/cat.png") + #expect(imagePart?["detail"] as? String == "high") + #expect((imagePart?["image_url"] as? [String: Any]) == nil, "Responses input_image must use string image_url, not nested object") + } +} diff --git a/TableProTests/Core/AI/OpenAIResponsesProviderParserTests.swift b/TableProTests/Core/AI/OpenAIResponsesProviderParserTests.swift new file mode 100644 index 000000000..a7852f8d2 --- /dev/null +++ b/TableProTests/Core/AI/OpenAIResponsesProviderParserTests.swift @@ -0,0 +1,162 @@ +// +// OpenAIResponsesProviderParserTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("OpenAIResponsesProvider stream parser") +struct OpenAIResponsesProviderParserTests { + private func parse(_ json: [String: Any], state: inout ResponsesStreamState) throws -> [ChatStreamEvent] { + try OpenAIResponsesProvider.parseEvent(json, state: &state) + } + + @Test("output_text.delta yields textDelta") + func outputTextDelta() throws { + var state = ResponsesStreamState() + let events = try parse([ + "type": "response.output_text.delta", + "delta": "hello world" + ], state: &state) + guard case .textDelta(let text) = events.first else { + Issue.record("expected textDelta; got \(events)") + return + } + #expect(text == "hello world") + } + + @Test("output_item.added reasoning yields reasoningStart") + func reasoningStart() throws { + var state = ResponsesStreamState() + let events = try parse([ + "type": "response.output_item.added", + "item": ["type": "reasoning", "id": "rs_abc"] + ], state: &state) + guard case .reasoningStart(let id) = events.first else { + Issue.record("expected reasoningStart; got \(events)") + return + } + #expect(id == "rs_abc") + #expect(state.openReasoningItemIDs.contains("rs_abc")) + } + + @Test("reasoning_summary_text.delta yields reasoningDelta") + func reasoningSummaryDelta() throws { + var state = ResponsesStreamState() + let events = try parse([ + "type": "response.reasoning_summary_text.delta", + "item_id": "rs_abc", + "delta": "I should look at" + ], state: &state) + guard case .reasoningDelta(let id, let text) = events.first else { + Issue.record("expected reasoningDelta; got \(events)") + return + } + #expect(id == "rs_abc") + #expect(text == "I should look at") + } + + @Test("output_item.done reasoning yields reasoningEnd with encrypted opaque and real itemID") + func reasoningEndCarriesEncryptedOpaque() throws { + var state = ResponsesStreamState() + state.openReasoningItemIDs.insert("rs_abc") + let events = try parse([ + "type": "response.output_item.done", + "item": [ + "type": "reasoning", + "id": "rs_abc", + "encrypted_content": "BLOB=" + ] + ], state: &state) + guard case .reasoningEnd(let id, let opaque) = events.first else { + Issue.record("expected reasoningEnd; got \(events)") + return + } + #expect(id == "rs_abc") + #expect(opaque?.kind == .openAIEncrypted) + #expect(opaque?.itemID == "rs_abc", "Server-issued reasoning id must round-trip via opaque") + #expect(opaque?.value == "BLOB=") + #expect(opaque?.blockType == "reasoning") + #expect(state.openReasoningItemIDs.isEmpty) + } + + @Test("output_item.added function_call yields toolUseStart") + func functionCallStart() throws { + var state = ResponsesStreamState() + let events = try parse([ + "type": "response.output_item.added", + "item": [ + "type": "function_call", + "call_id": "call_xyz", + "name": "execute_query" + ] + ], state: &state) + guard case .toolUseStart(let id, let name) = events.first else { + Issue.record("expected toolUseStart; got \(events)") + return + } + #expect(id == "call_xyz") + #expect(name == "execute_query") + } + + @Test("function_call_arguments.delta yields toolUseDelta") + func functionCallArgumentsDelta() throws { + var state = ResponsesStreamState() + let events = try parse([ + "type": "response.function_call_arguments.delta", + "call_id": "call_xyz", + "delta": #"{"query":"# + ], state: &state) + guard case .toolUseDelta(let id, let delta) = events.first else { + Issue.record("expected toolUseDelta; got \(events)") + return + } + #expect(id == "call_xyz") + #expect(delta == #"{"query":"#) + } + + @Test("completed event captures usage tokens") + func completedCapturesUsage() throws { + var state = ResponsesStreamState() + _ = try parse([ + "type": "response.completed", + "response": [ + "usage": [ + "input_tokens": 42, + "output_tokens": 17 + ] + ] + ], state: &state) + #expect(state.inputTokens == 42) + #expect(state.outputTokens == 17) + guard case .usage(let usage) = state.finalUsageEvent() else { + Issue.record("expected usage event") + return + } + #expect(usage.inputTokens == 42) + #expect(usage.outputTokens == 17) + } + + @Test("response.failed throws streamingFailed with error message") + func responseFailedThrows() throws { + var state = ResponsesStreamState() + #expect(throws: AIProviderError.self) { + _ = try OpenAIResponsesProvider.parseEvent([ + "type": "response.failed", + "response": ["error": ["message": "rate limit exceeded"]] + ], state: &state) + } + } + + @Test("decodeStreamLine handles data prefix and DONE sentinel") + func decodeStreamLineFraming() { + let json = OpenAIResponsesProvider.decodeStreamLine( + #"data: {"type":"response.created","response":{"id":"r1"}}"# + ) + #expect(json?["type"] as? String == "response.created") + #expect(OpenAIResponsesProvider.decodeStreamLine("data: [DONE]") == nil) + #expect(OpenAIResponsesProvider.decodeStreamLine(": comment line") == nil) + } +} diff --git a/TableProTests/Core/AI/StrictToolSchemaTests.swift b/TableProTests/Core/AI/StrictToolSchemaTests.swift new file mode 100644 index 000000000..5dc11d978 --- /dev/null +++ b/TableProTests/Core/AI/StrictToolSchemaTests.swift @@ -0,0 +1,87 @@ +// +// StrictToolSchemaTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Strict tool schema audit") +struct StrictToolSchemaTests { + private let tools: [any ChatTool] = [ + ListConnectionsChatTool(), + ListDatabasesChatTool(), + ListTablesChatTool(), + DescribeTableChatTool(), + GetTableDDLChatTool(), + GetConnectionStatusChatTool(), + ExecuteQueryChatTool(), + ConfirmDestructiveOperationChatTool() + ] + + @Test("ChatToolSpec.strict defaults to true") + func strictDefaultsTrue() { + let spec = ChatToolSpec( + name: "test", + description: "test", + inputSchema: .object([:]) + ) + #expect(spec.strict == true) + } + + @Test("ChatToolSpec.strict survives Codable round-trip with absent key") + func strictBackwardCompatible() throws { + let json = #"{"name":"old","description":"old tool","inputSchema":{"object":{}}}"# + let decoded = try JSONDecoder().decode(ChatToolSpec.self, from: Data(json.utf8)) + #expect(decoded.strict == true, "Legacy specs without strict key must default to true") + } + + @Test("ChatToolSchemaBuilder.object emits additionalProperties false") + func builderEmitsAdditionalPropertiesFalse() throws { + let schema = ChatToolSchemaBuilder.object(properties: [ + "name": ChatToolSchemaBuilder.string(description: "x") + ]) + let dict = try (schema.jsonObject() as? [String: Any]) ?? [:] + #expect(dict["additionalProperties"] as? Bool == false) + } + + @Test("ChatToolSchemaBuilder.object marks all properties required when not specified") + func builderAutoIncludesRequired() throws { + let schema = ChatToolSchemaBuilder.object(properties: [ + "a": ChatToolSchemaBuilder.string(description: "x"), + "b": ChatToolSchemaBuilder.string(description: "y") + ]) + let dict = try (schema.jsonObject() as? [String: Any]) ?? [:] + let required = (dict["required"] as? [String]) ?? [] + #expect(Set(required) == Set(["a", "b"])) + } + + @Test("ChatToolSchemaBuilder.string with optional emits nullable union") + func nullableUnionForOptional() throws { + let schema = ChatToolSchemaBuilder.string(description: "x", optional: true) + let dict = try (schema.jsonObject() as? [String: Any]) ?? [:] + let type = dict["type"] as? [String] + #expect(type == ["string", "null"]) + } + + @Test("All registered tools have closed schemas with no missing required keys") + func toolsAreStrictCompliant() throws { + for tool in tools { + let spec = tool.spec + #expect(spec.strict == true, "\(tool.name) must default to strict") + let parameters = try (spec.inputSchema.jsonObject() as? [String: Any]) ?? [:] + #expect( + parameters["additionalProperties"] as? Bool == false, + "\(tool.name) schema must set additionalProperties: false" + ) + let properties = (parameters["properties"] as? [String: Any]) ?? [:] + let required = Set((parameters["required"] as? [String]) ?? []) + let missing = Set(properties.keys).subtracting(required) + #expect( + missing.isEmpty, + "\(tool.name): properties \(missing) are not in required; strict mode rejects this" + ) + } + } +} From b8ed872db924f429bbadeb5b0f48850b32704883 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Thu, 14 May 2026 08:38:01 +0700 Subject: [PATCH 02/10] fix(ai-providers): request reasoning summary so streaming deltas reach the UI --- TablePro/Core/AI/OpenAIResponsesProvider.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TablePro/Core/AI/OpenAIResponsesProvider.swift b/TablePro/Core/AI/OpenAIResponsesProvider.swift index 11ed721cd..c773ba962 100644 --- a/TablePro/Core/AI/OpenAIResponsesProvider.swift +++ b/TablePro/Core/AI/OpenAIResponsesProvider.swift @@ -150,7 +150,7 @@ final class OpenAIResponsesProvider: ChatTransport { } if let effort = options.reasoningEffort { - body["reasoning"] = ["effort": effort.openAIWireValue] + body["reasoning"] = ["effort": effort.openAIWireValue, "summary": "auto"] body["include"] = ["reasoning.encrypted_content"] } From 86d46f657fc7c7727bdfed795d7321ab1b8cc022 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Thu, 14 May 2026 08:38:40 +0700 Subject: [PATCH 03/10] fix(ai-providers): restrict reasoning-effort fallback to low/medium/high for non-curated models --- TablePro/Core/AI/Registry/AIProviderDescriptor.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TablePro/Core/AI/Registry/AIProviderDescriptor.swift b/TablePro/Core/AI/Registry/AIProviderDescriptor.swift index 198402f4a..0f54f073a 100644 --- a/TablePro/Core/AI/Registry/AIProviderDescriptor.swift +++ b/TablePro/Core/AI/Registry/AIProviderDescriptor.swift @@ -56,7 +56,7 @@ struct AIProviderDescriptor: Sendable { if let curated = curatedModel(forID: id), !curated.supportedEffortLevels.isEmpty { return curated.supportedEffortLevels } - return ReasoningEffort.allCases + return [.low, .medium, .high] } init( From 428e0812b207f8d85a15c1a6f5d40b494ecd659d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Thu, 14 May 2026 08:39:20 +0700 Subject: [PATCH 04/10] fix(ai-chat): sanitise AIImageCache filenames and serialise file ops --- TablePro/Core/AI/Images/AIImageCache.swift | 65 +++++++++++++++------- 1 file changed, 44 insertions(+), 21 deletions(-) diff --git a/TablePro/Core/AI/Images/AIImageCache.swift b/TablePro/Core/AI/Images/AIImageCache.swift index 388c9e000..fb5d465ae 100644 --- a/TablePro/Core/AI/Images/AIImageCache.swift +++ b/TablePro/Core/AI/Images/AIImageCache.swift @@ -1,18 +1,18 @@ -// -// AIImageCache.swift -// TablePro -// - import AppKit import Foundation import os +/// Disk-backed cache for chat-attached images. File operations are serialised +/// on `queue`; `cacheDirectory` is immutable after init so reads can also run +/// concurrently from any thread. Filenames decoded from history are checked +/// to belong to the cache directory, defending against `../` traversal. final class AIImageCache: @unchecked Sendable { static let shared = AIImageCache() private static let logger = Logger(subsystem: "com.TablePro", category: "AIImageCache") private let cacheDirectory: URL + private let queue = DispatchQueue(label: "com.TablePro.AIImageCache", qos: .utility) private init() { let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first @@ -27,16 +27,19 @@ final class AIImageCache: @unchecked Sendable { let ext = fileExtension(for: mediaType) let filename = "\(UUID().uuidString).\(ext)" let url = cacheDirectory.appendingPathComponent(filename) - do { - try data.write(to: url, options: .atomic) - } catch { - Self.logger.error("Failed to write image: \(error.localizedDescription, privacy: .public)") + queue.sync { + do { + try data.write(to: url, options: .atomic) + } catch { + Self.logger.error("Failed to write image: \(error.localizedDescription, privacy: .public)") + } } return filename } func read(filename: String) -> Data? { - try? Data(contentsOf: cacheDirectory.appendingPathComponent(filename)) + guard let url = safeURL(for: filename) else { return nil } + return queue.sync { try? Data(contentsOf: url) } } func loadImage(filename: String) -> NSImage? { @@ -45,23 +48,43 @@ final class AIImageCache: @unchecked Sendable { } func delete(filename: String) { - let url = cacheDirectory.appendingPathComponent(filename) - try? FileManager.default.removeItem(at: url) + guard let url = safeURL(for: filename) else { return } + queue.sync { + try? FileManager.default.removeItem(at: url) + } } func purgeOlderThan(seconds: TimeInterval) { - let cutoff = Date().addingTimeInterval(-seconds) - let urls = (try? FileManager.default.contentsOfDirectory( - at: cacheDirectory, - includingPropertiesForKeys: [.contentModificationDateKey] - )) ?? [] - for url in urls { - let resources = try? url.resourceValues(forKeys: [.contentModificationDateKey]) - guard let date = resources?.contentModificationDate, date < cutoff else { continue } - try? FileManager.default.removeItem(at: url) + queue.sync { + let cutoff = Date().addingTimeInterval(-seconds) + let urls = (try? FileManager.default.contentsOfDirectory( + at: cacheDirectory, + includingPropertiesForKeys: [.contentModificationDateKey] + )) ?? [] + for url in urls { + let resources = try? url.resourceValues(forKeys: [.contentModificationDateKey]) + guard let date = resources?.contentModificationDate, date < cutoff else { continue } + try? FileManager.default.removeItem(at: url) + } } } + /// Resolves a filename to a URL only when it stays inside `cacheDirectory`. + /// Rejects empty input, path separators, parent-directory components, and + /// any symlink-resolved path that escapes the cache root. + private func safeURL(for filename: String) -> URL? { + guard !filename.isEmpty, + !filename.contains("/"), + !filename.contains("\\"), + filename != ".", + filename != ".." + else { return nil } + let candidate = cacheDirectory.appendingPathComponent(filename).standardizedFileURL + let root = cacheDirectory.standardizedFileURL.path + guard candidate.path.hasPrefix(root + "/") || candidate.path == root else { return nil } + return candidate + } + private func fileExtension(for mediaType: String) -> String { switch mediaType { case "image/png": return "png" From 75baa5bbb4f8bf3902c76004c483b917d96acb5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Thu, 14 May 2026 08:40:54 +0700 Subject: [PATCH 05/10] fix(ai-chat): always redraw chat images into sRGB to strip ICC and IPTC metadata --- .../Core/AI/Images/ChatImageConverter.swift | 35 +++++++++---------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/TablePro/Core/AI/Images/ChatImageConverter.swift b/TablePro/Core/AI/Images/ChatImageConverter.swift index e072e2f65..8e9024135 100644 --- a/TablePro/Core/AI/Images/ChatImageConverter.swift +++ b/TablePro/Core/AI/Images/ChatImageConverter.swift @@ -78,7 +78,7 @@ enum ChatImageConverter { guard let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil) else { throw ChatImageConverterError.decodingFailed } - let scaledImage = downscaleIfNeeded(cgImage) + let scaledImage = redrawInSRGB(cgImage) let targetType: CFString = useJPEG ? UTType.jpeg.identifier as CFString : UTType.png.identifier as CFString let mediaType = useJPEG ? "image/jpeg" : "image/png" let output = NSMutableData() @@ -98,28 +98,27 @@ enum ChatImageConverter { return ChatImageInput(source: .cacheFile(filename: filename, mediaType: mediaType)) } - private static func downscaleIfNeeded(_ image: CGImage) -> CGImage { - let width = CGFloat(image.width) - let height = CGFloat(image.height) - let longEdge = max(width, height) - guard longEdge > maxLongEdgePixels else { return image } - let scale = maxLongEdgePixels / longEdge - let newWidth = Int(width * scale) - let newHeight = Int(height * scale) - let colorSpace = image.colorSpace ?? CGColorSpaceCreateDeviceRGB() - let bitsPerComponent = 8 - let bytesPerRow = newWidth * 4 + /// Always re-draws into a premultiplied-RGBA sRGB context. Strips ICC/IPTC + /// metadata carried on the source CGImage (CMYK TIFF, HEIC with embedded + /// EXIF), and downscales to `maxLongEdgePixels` when needed. + private static func redrawInSRGB(_ image: CGImage) -> CGImage { + let srcW = CGFloat(image.width) + let srcH = CGFloat(image.height) + let longEdge = max(srcW, srcH) + let scale = longEdge > maxLongEdgePixels ? maxLongEdgePixels / longEdge : 1 + let width = max(1, Int(srcW * scale)) + let height = max(1, Int(srcH * scale)) guard let context = CGContext( data: nil, - width: newWidth, - height: newHeight, - bitsPerComponent: bitsPerComponent, - bytesPerRow: bytesPerRow, - space: colorSpace, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: width * 4, + space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue ) else { return image } context.interpolationQuality = .high - context.draw(image, in: CGRect(x: 0, y: 0, width: newWidth, height: newHeight)) + context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) return context.makeImage() ?? image } From e26375b969e5307c02d2fe8f876dab3ebe6f9274 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Thu, 14 May 2026 08:41:35 +0700 Subject: [PATCH 06/10] fix(ai-providers): include null in optional enumString schemas for strict mode --- TablePro/Core/AI/Chat/ChatToolSchemaBuilder.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/TablePro/Core/AI/Chat/ChatToolSchemaBuilder.swift b/TablePro/Core/AI/Chat/ChatToolSchemaBuilder.swift index 2971056f6..52feecc49 100644 --- a/TablePro/Core/AI/Chat/ChatToolSchemaBuilder.swift +++ b/TablePro/Core/AI/Chat/ChatToolSchemaBuilder.swift @@ -24,8 +24,12 @@ enum ChatToolSchemaBuilder { } static func enumString(_ values: [String], description: String, optional: Bool = false) -> JsonValue { - scalar("string", description: description, optional: optional, extras: [ - "enum": .array(values.map(JsonValue.string)) + var members = values.map(JsonValue.string) + if optional { + members.append(.null) + } + return scalar("string", description: description, optional: optional, extras: [ + "enum": .array(members) ]) } From 9031f02ce4d1a6781386d1d347038331ebdfadfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Thu, 14 May 2026 08:42:33 +0700 Subject: [PATCH 07/10] fix(ai-providers): keep assistant function_call adjacent to its output and log dropped reasoning items --- TablePro/Core/AI/OpenAIResponsesProvider.swift | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/TablePro/Core/AI/OpenAIResponsesProvider.swift b/TablePro/Core/AI/OpenAIResponsesProvider.swift index c773ba962..fda04e5e8 100644 --- a/TablePro/Core/AI/OpenAIResponsesProvider.swift +++ b/TablePro/Core/AI/OpenAIResponsesProvider.swift @@ -175,12 +175,16 @@ final class OpenAIResponsesProvider: ChatTransport { var messageParts: [[String: Any]] = [] if turn.role == .assistant { + var hasFunctionCall = false for block in turn.blocks { switch block.kind { case .reasoning(let reasoning): guard let opaque = reasoning.opaque, - opaque.kind == .openAIEncrypted, - !opaque.itemID.isEmpty else { continue } + opaque.kind == .openAIEncrypted else { continue } + if opaque.itemID.isEmpty { + Self.logger.warning("Dropping reasoning item without itemID; history may be inconsistent") + continue + } flushAssistantMessage(parts: &messageParts, into: &items) items.append([ "type": "reasoning", @@ -198,11 +202,19 @@ final class OpenAIResponsesProvider: ChatTransport { "name": useBlock.name, "arguments": useBlock.input.jsonString() ]) + hasFunctionCall = true case .toolResult, .attachment, .image: continue } } - flushAssistantMessage(parts: &messageParts, into: &items) + if hasFunctionCall { + if !messageParts.isEmpty { + Self.logger.warning("Dropping \(messageParts.count) text parts after function_call to keep tool-call adjacent to its output") + messageParts.removeAll() + } + } else { + flushAssistantMessage(parts: &messageParts, into: &items) + } return items } From b969513f6a4c63af646a4d97c607d30cbbc497bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Thu, 14 May 2026 08:43:11 +0700 Subject: [PATCH 08/10] fix(ai-chat): clear staged image attachments and their cache files on session reset --- TablePro/ViewModels/AIChatViewModel.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/TablePro/ViewModels/AIChatViewModel.swift b/TablePro/ViewModels/AIChatViewModel.swift index 32b01b87d..3c09401d7 100644 --- a/TablePro/ViewModels/AIChatViewModel.swift +++ b/TablePro/ViewModels/AIChatViewModel.swift @@ -251,6 +251,12 @@ final class AIChatViewModel { activeConversationID = nil sessionApprovedConnections = [] streamingState = .idle + for image in attachedImages { + if case .cacheFile(let filename, _) = image.source { + AIImageCache.shared.delete(filename: filename) + } + } + attachedImages = [] } func handleFixError(query: String, error: String) { From 90df309f30cb5827ecba9fd463594fb3f7f5e9fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Thu, 14 May 2026 08:44:25 +0700 Subject: [PATCH 09/10] style(ai-providers): drop new explanatory comments per CLAUDE.md no-comments rule --- TablePro/Core/AI/AnthropicProvider.swift | 8 -------- TablePro/Core/AI/OpenAICompatibleProvider.swift | 10 ---------- 2 files changed, 18 deletions(-) diff --git a/TablePro/Core/AI/AnthropicProvider.swift b/TablePro/Core/AI/AnthropicProvider.swift index fdf764f12..8c5b4dc0c 100644 --- a/TablePro/Core/AI/AnthropicProvider.swift +++ b/TablePro/Core/AI/AnthropicProvider.swift @@ -214,10 +214,6 @@ final class AnthropicProvider: ChatTransport { return false } - /// 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. static func decodeStreamLine(_ line: String) -> [String: Any]? { guard line.hasPrefix("data: ") else { return nil } let jsonString = String(line.dropFirst(6)) @@ -228,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 diff --git a/TablePro/Core/AI/OpenAICompatibleProvider.swift b/TablePro/Core/AI/OpenAICompatibleProvider.swift index b578a744c..84e3c0dfc 100644 --- a/TablePro/Core/AI/OpenAICompatibleProvider.swift +++ b/TablePro/Core/AI/OpenAICompatibleProvider.swift @@ -87,9 +87,6 @@ final class OpenAICompatibleProvider: ChatTransport { } } - /// Decodes one streaming line. OpenAI/OpenRouter/Custom use SSE framing - /// (`data: {...}`); Ollama emits NDJSON (one JSON object per line). The - /// `[DONE]` sentinel returns nil; the caller should break on it. static func decodeStreamLine(_ line: String, providerType: AIProviderType) -> [String: Any]? { let jsonString: String if providerType == .ollama { @@ -107,10 +104,6 @@ final class OpenAICompatibleProvider: ChatTransport { return json } - /// Translate one chunk JSON to events. Mutates state to thread tool-call - /// index→id mapping, ordering, and token counters across chunks. - /// Returns `(events, shouldBreak)` so the caller can stop the stream when - /// Ollama emits `done: true`. static func parseChunk( _ json: [String: Any], state: inout OpenAIStreamState @@ -152,9 +145,6 @@ final class OpenAICompatibleProvider: ChatTransport { state.outputTokens = evalCount } - // Ollama signals stream-end via `done: true`. We flush again here only - // when finish_reason didn't already drain the tool-call map (which - // typically isn't set on Ollama responses). let shouldBreak = (json["done"] as? Bool) == true if shouldBreak, !state.toolCallIndexToId.isEmpty { events.append(contentsOf: state.flushToolUseEnds()) From 2e9014b085e4f19501c741516c7effb5095c3c15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Thu, 14 May 2026 08:51:19 +0700 Subject: [PATCH 10/10] test(ai-providers): cover null enum, trailing text drop, empty itemID, reasoning Codable round-trip --- ...OpenAIResponsesProviderEncodingTests.swift | 52 +++++++++++++++++++ .../Core/AI/StrictToolSchemaTests.swift | 35 +++++++++++++ 2 files changed, 87 insertions(+) diff --git a/TableProTests/Core/AI/OpenAIResponsesProviderEncodingTests.swift b/TableProTests/Core/AI/OpenAIResponsesProviderEncodingTests.swift index 9b046d4ca..201fa58fd 100644 --- a/TableProTests/Core/AI/OpenAIResponsesProviderEncodingTests.swift +++ b/TableProTests/Core/AI/OpenAIResponsesProviderEncodingTests.swift @@ -84,6 +84,58 @@ struct OpenAIResponsesProviderEncodingTests { #expect(items[2]["type"] as? String == "function_call") } + @Test("assistant turn with [toolUse, text] drops trailing text to keep tool-call adjacent to its output") + func assistantTrailingTextAfterToolUseDropped() throws { + let toolUse = ToolUseBlock(id: "call_1", name: "ping", input: .object([:])) + let turn = ChatTurnWire(role: .assistant, blocks: [ + .toolUse(toolUse), + .text("trailing chatter") + ]) + let items = try OpenAIResponsesProvider.encodeTurn(turn) + #expect(items.count == 1, "Trailing text after function_call must not produce a message item") + #expect(items[0]["type"] as? String == "function_call") + } + + @Test("assistant reasoning with empty itemID is skipped, not emitted") + func reasoningWithEmptyItemIDSkipped() throws { + let badOpaque = ReasoningOpaque( + kind: .openAIEncrypted, + itemID: "", + value: "ENC=", + blockType: "reasoning" + ) + let turn = ChatTurnWire(role: .assistant, blocks: [ + .reasoning(ReasoningBlock(opaque: badOpaque)), + .text("hi") + ]) + let items = try OpenAIResponsesProvider.encodeTurn(turn) + #expect(items.count == 1, "Empty itemID reasoning item must be dropped") + #expect(items[0]["type"] as? String == "message") + } + + @Test("ChatContentBlockWire(.reasoning) round-trips through Codable preserving every opaque field") + func reasoningBlockCodableRoundTrip() throws { + let opaque = ReasoningOpaque( + kind: .openAIEncrypted, + itemID: "rs_roundtrip", + value: "BLOB==", + blockType: "reasoning" + ) + let block: ChatContentBlockWire = .reasoning(ReasoningBlock(text: "think", opaque: opaque)) + let encoded = try JSONEncoder().encode(block) + let decoded = try JSONDecoder().decode(ChatContentBlockWire.self, from: encoded) + switch decoded.kind { + case .reasoning(let restored): + #expect(restored.text == "think") + #expect(restored.opaque?.kind == .openAIEncrypted) + #expect(restored.opaque?.itemID == "rs_roundtrip") + #expect(restored.opaque?.value == "BLOB==") + #expect(restored.opaque?.blockType == "reasoning") + default: + Issue.record("decoded was not a reasoning block") + } + } + @Test("user turn with tool_result emits function_call_output with matching call_id") func toolResultEncoding() throws { let result = ToolResultBlock(toolUseId: "call_1", content: "ok") diff --git a/TableProTests/Core/AI/StrictToolSchemaTests.swift b/TableProTests/Core/AI/StrictToolSchemaTests.swift index 5dc11d978..fdb5734c4 100644 --- a/TableProTests/Core/AI/StrictToolSchemaTests.swift +++ b/TableProTests/Core/AI/StrictToolSchemaTests.swift @@ -46,6 +46,41 @@ struct StrictToolSchemaTests { #expect(dict["additionalProperties"] as? Bool == false) } + @Test("enumString(optional:true) appends null to the enum array under strict mode") + func enumStringOptionalIncludesNull() throws { + let schema = ChatToolSchemaBuilder.enumString( + ["asc", "desc"], + description: "sort direction", + optional: true + ) + let dict = try (schema.jsonObject() as? [String: Any]) ?? [:] + let typeValue = dict["type"] + if let union = typeValue as? [String] { + #expect(union.contains("string")) + #expect(union.contains("null")) + } else { + Issue.record("expected union type, got \(String(describing: typeValue))") + } + let enumValues = dict["enum"] as? [Any] ?? [] + let stringValues = enumValues.compactMap { $0 as? String } + #expect(stringValues.contains("asc")) + #expect(stringValues.contains("desc")) + let hasNull = enumValues.contains(where: { $0 is NSNull }) + #expect(hasNull, "enum array must include null when type union includes null") + } + + @Test("enumString(optional:false) does not include null in the enum array") + func enumStringRequiredOmitsNull() throws { + let schema = ChatToolSchemaBuilder.enumString( + ["asc", "desc"], + description: "sort direction" + ) + let dict = try (schema.jsonObject() as? [String: Any]) ?? [:] + let enumValues = dict["enum"] as? [Any] ?? [] + let hasNull = enumValues.contains(where: { $0 is NSNull }) + #expect(!hasNull) + } + @Test("ChatToolSchemaBuilder.object marks all properties required when not specified") func builderAutoIncludesRequired() throws { let schema = ChatToolSchemaBuilder.object(properties: [