diff --git a/CHANGELOG.md b/CHANGELOG.md index f64d14945..2b4922f82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- AI Chat: DeepSeek V4 thinking content (`reasoning_content`) is now captured during streaming and passed back in subsequent turns, fixing 400 errors when using deepseek-v4-pro or deepseek-v4-flash. - MongoDB: the connection form now shows a Username field. It was hidden for databases where authentication is optional, so connections to auth-enabled servers saved with no credentials and every query failed with "requires authentication" even though the connection looked healthy. - SQL import dropped statements when the database executed them slower than the file was parsed, so a re-imported export could fail with errors like "relation does not exist". The parser now waits for each statement to be consumed before reading more. (#1264) - SQL import ignored the database dialect, so PostgreSQL dumps with dollar-quoted function bodies were split at semicolons inside the body. (#1264) diff --git a/TablePro/Core/AI/OpenAICompatibleProvider.swift b/TablePro/Core/AI/OpenAICompatibleProvider.swift index 84e3c0dfc..41c492125 100644 --- a/TablePro/Core/AI/OpenAICompatibleProvider.swift +++ b/TablePro/Core/AI/OpenAICompatibleProvider.swift @@ -71,6 +71,9 @@ final class OpenAICompatibleProvider: ChatTransport { for event in result.events { continuation.yield(event) } if result.shouldBreak { break } } + if let reasoningEnd = state.flushReasoningEnd() { + continuation.yield(reasoningEnd) + } if let usage = state.finalUsageEvent() { continuation.yield(usage) } @@ -113,6 +116,19 @@ final class OpenAICompatibleProvider: ChatTransport { let firstChoice = choices?.first let delta = firstChoice?["delta"] as? [String: Any] + if let delta, let reasoningContent = delta["reasoning_content"] as? String, !reasoningContent.isEmpty { + let reasoningID: String + if let existing = state.reasoningBlockID { + reasoningID = existing + } else { + let newID = "reasoning_\(UUID().uuidString.prefix(8))" + state.reasoningBlockID = newID + events.append(.reasoningStart(id: newID)) + reasoningID = newID + } + events.append(.reasoningDelta(id: reasoningID, text: reasoningContent)) + } + if let delta, let content = delta["content"] as? String, !content.isEmpty { events.append(.textDelta(content)) } else if let message = json["message"] as? [String: Any], @@ -128,9 +144,13 @@ final class OpenAICompatibleProvider: ChatTransport { events.append(contentsOf: handleOllamaToolCalls(toolCalls, state: &state)) } - if let finishReason = firstChoice?["finish_reason"] as? String, - finishReason == "tool_calls" { - events.append(contentsOf: state.flushToolUseEnds()) + if let finishReason = firstChoice?["finish_reason"] as? String, !finishReason.isEmpty { + if let event = state.flushReasoningEnd() { + events.append(event) + } + if finishReason == "tool_calls" { + events.append(contentsOf: state.flushToolUseEnds()) + } } if let usage = json["usage"] as? [String: Any], @@ -373,6 +393,10 @@ final class OpenAICompatibleProvider: ChatTransport { ] ] } + let reasoningText = Self.plainReasoningText(from: turn) + if !reasoningText.isEmpty { + message["reasoning_content"] = reasoningText + } return [message] } @@ -411,10 +435,26 @@ final class OpenAICompatibleProvider: ChatTransport { } guard !textContent.isEmpty else { return [] } - return [[ + var message: [String: Any] = [ "role": turn.role.rawValue, "content": textContent - ]] + ] + if turn.role == .assistant { + let reasoningText = Self.plainReasoningText(from: turn) + if !reasoningText.isEmpty { + message["reasoning_content"] = reasoningText + } + } + return [message] + } + + private static func plainReasoningText(from turn: ChatTurnWire) -> String { + turn.blocks.compactMap { block -> String? in + guard case .reasoning(let rb) = block.kind, + rb.opaque == nil, + let text = rb.text else { return nil } + return text + }.joined() } private func chatCompletionsImagePart(_ input: ChatImageInput) -> [String: Any]? { @@ -523,6 +563,13 @@ struct OpenAIStreamState { var outputTokens: Int = 0 var toolCallIndexToId: [Int: String] = [:] var toolCallOrder: [Int] = [] + var reasoningBlockID: String? + + mutating func flushReasoningEnd() -> ChatStreamEvent? { + guard let id = reasoningBlockID else { return nil } + reasoningBlockID = nil + return .reasoningEnd(id: id, opaque: nil) + } /// Yield `.toolUseEnd` for every tracked tool call and clear the map. /// Called when the provider signals tool-call completion (`finish_reason` diff --git a/TableProTests/Core/AI/OpenAICompatibleProviderEncodingTests.swift b/TableProTests/Core/AI/OpenAICompatibleProviderEncodingTests.swift index e0c44d0d4..4f03d5fa7 100644 --- a/TableProTests/Core/AI/OpenAICompatibleProviderEncodingTests.swift +++ b/TableProTests/Core/AI/OpenAICompatibleProviderEncodingTests.swift @@ -92,6 +92,59 @@ struct OpenAICompatibleProviderEncodingTests { #expect(messages[1]["tool_call_id"] as? String == "call_2") } + @Test("Assistant turn with plain reasoning block includes reasoning_content field") + func assistantWithPlainReasoning() { + let reasoning = ReasoningBlock(text: "I think about this step by step", opaque: nil) + let turn = ChatTurnWire(role: .assistant, blocks: [ + .reasoning(reasoning), + .text("Here is my answer") + ]) + let messages = makeProvider().encodeTurn(turn) + #expect(messages.count == 1) + #expect(messages[0]["role"] as? String == "assistant") + #expect(messages[0]["content"] as? String == "Here is my answer") + #expect(messages[0]["reasoning_content"] as? String == "I think about this step by step") + } + + @Test("Assistant turn with Anthropic-signed reasoning does not include reasoning_content") + func assistantWithAnthropicReasoningOmitsField() { + let opaque = ReasoningOpaque(kind: .anthropicSignature, itemID: "blk_1", value: "sig123", blockType: "thinking") + let reasoning = ReasoningBlock(text: "hidden thinking", opaque: opaque) + let turn = ChatTurnWire(role: .assistant, blocks: [ + .reasoning(reasoning), + .text("My answer") + ]) + let messages = makeProvider().encodeTurn(turn) + #expect(messages.count == 1) + #expect(messages[0]["reasoning_content"] == nil) + } + + @Test("Assistant turn with tool calls and plain reasoning includes reasoning_content") + func assistantWithToolCallsAndReasoning() { + let reasoning = ReasoningBlock(text: "need to check tables", opaque: nil) + let toolUse = ToolUseBlock(id: "call_1", name: "list_tables", input: .object([:])) + let turn = ChatTurnWire(role: .assistant, blocks: [ + .reasoning(reasoning), + .toolUse(toolUse) + ]) + let messages = makeProvider().encodeTurn(turn) + #expect(messages.count == 1) + #expect(messages[0]["tool_calls"] != nil) + #expect(messages[0]["reasoning_content"] as? String == "need to check tables") + } + + @Test("User turn never includes reasoning_content even with reasoning blocks") + func userTurnWithReasoningOmitsField() { + let reasoning = ReasoningBlock(text: "user reasoning", opaque: nil) + let turn = ChatTurnWire(role: .user, blocks: [ + .reasoning(reasoning), + .text("my question") + ]) + let messages = makeProvider().encodeTurn(turn) + #expect(messages.count == 1) + #expect(messages[0]["reasoning_content"] == nil) + } + @Test("Empty text turn returns no messages") func emptyTurnYieldsNothing() { let turn = ChatTurnWire(role: .user, blocks: [.text("")]) diff --git a/TableProTests/Core/AI/OpenAICompatibleProviderParserTests.swift b/TableProTests/Core/AI/OpenAICompatibleProviderParserTests.swift index 275558b9d..0689fa912 100644 --- a/TableProTests/Core/AI/OpenAICompatibleProviderParserTests.swift +++ b/TableProTests/Core/AI/OpenAICompatibleProviderParserTests.swift @@ -4,8 +4,8 @@ // import Foundation -import TableProPluginKit @testable import TablePro +import TableProPluginKit import Testing @Suite("OpenAICompatibleProvider stream parser") @@ -175,6 +175,112 @@ struct OpenAICompatibleProviderParserTests { #expect(text == "hi") } + @Test("delta.reasoning_content on first chunk emits reasoningStart then reasoningDelta") + func reasoningContentFirstChunk() { + var state = OpenAIStreamState() + let result = OpenAICompatibleProvider.parseChunk([ + "choices": [[ + "delta": ["reasoning_content": "Let me think..."] + ]] + ], state: &state) + #expect(result.events.count == 2) + guard case .reasoningStart(let id) = result.events[0] else { + Issue.record("expected reasoningStart; got \(result.events[0])") + return + } + guard case .reasoningDelta(let deltaID, let text) = result.events[1] else { + Issue.record("expected reasoningDelta; got \(result.events[1])") + return + } + #expect(deltaID == id) + #expect(text == "Let me think...") + #expect(state.reasoningBlockID == id) + } + + @Test("Subsequent delta.reasoning_content chunks emit only reasoningDelta (no duplicate start)") + func reasoningContentSubsequentChunk() { + var state = OpenAIStreamState() + state.reasoningBlockID = "reasoning_abc" + let result = OpenAICompatibleProvider.parseChunk([ + "choices": [[ + "delta": ["reasoning_content": " more thinking"] + ]] + ], state: &state) + #expect(result.events.count == 1) + guard case .reasoningDelta(let id, let text) = result.events[0] else { + Issue.record("expected reasoningDelta; got \(result.events)") + return + } + #expect(id == "reasoning_abc") + #expect(text == " more thinking") + } + + @Test("finish_reason: stop flushes open reasoning block as reasoningEnd with nil opaque") + func finishReasonStopClosesReasoningBlock() { + var state = OpenAIStreamState() + state.reasoningBlockID = "reasoning_xyz" + let result = OpenAICompatibleProvider.parseChunk([ + "choices": [["finish_reason": "stop"]] + ], state: &state) + #expect(result.events.count == 1) + guard case .reasoningEnd(let id, let opaque) = result.events[0] else { + Issue.record("expected reasoningEnd; got \(result.events)") + return + } + #expect(id == "reasoning_xyz") + #expect(opaque == nil) + #expect(state.reasoningBlockID == nil) + } + + @Test("reasoning_content followed by finish_reason in same chunk emits start, delta, end") + func reasoningContentWithFinishReason() { + var state = OpenAIStreamState() + let result = OpenAICompatibleProvider.parseChunk([ + "choices": [[ + "delta": ["reasoning_content": "final thought"], + "finish_reason": "stop" + ]] + ], state: &state) + let kinds = result.events.map { event -> String in + switch event { + case .reasoningStart: return "start" + case .reasoningDelta: return "delta" + case .reasoningEnd: return "end" + default: return "other" + } + } + #expect(kinds == ["start", "delta", "end"]) + #expect(state.reasoningBlockID == nil) + } + + @Test("delta.reasoning_content: null is ignored and does not emit reasoningStart") + func reasoningContentNullIgnored() { + var state = OpenAIStreamState() + let result = OpenAICompatibleProvider.parseChunk([ + "choices": [[ + "delta": ["content": "hello", "reasoning_content": NSNull()] + ]] + ], state: &state) + #expect(state.reasoningBlockID == nil) + #expect(result.events.count == 1) + guard case .textDelta = result.events[0] else { + Issue.record("expected only textDelta; got \(result.events)") + return + } + } + + @Test("finish_reason: stop does not flush pending tool calls") + func finishReasonStopLeavesToolCallsIntact() { + var state = OpenAIStreamState() + state.toolCallIndexToId = [0: "call_a"] + state.toolCallOrder = [0] + let result = OpenAICompatibleProvider.parseChunk([ + "choices": [["finish_reason": "stop"]] + ], state: &state) + #expect(result.events.isEmpty) + #expect(state.toolCallIndexToId[0] == "call_a") + } + @Test("Empty chunk yields no events and doesn't break") func emptyChunk() { var state = OpenAIStreamState()