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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
57 changes: 52 additions & 5 deletions TablePro/Core/AI/OpenAICompatibleProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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],
Expand All @@ -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],
Expand Down Expand Up @@ -373,6 +393,10 @@ final class OpenAICompatibleProvider: ChatTransport {
]
]
}
let reasoningText = Self.plainReasoningText(from: turn)
if !reasoningText.isEmpty {
message["reasoning_content"] = reasoningText
}
return [message]
}

Expand Down Expand Up @@ -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]? {
Expand Down Expand Up @@ -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`
Expand Down
53 changes: 53 additions & 0 deletions TableProTests/Core/AI/OpenAICompatibleProviderEncodingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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("")])
Expand Down
108 changes: 107 additions & 1 deletion TableProTests/Core/AI/OpenAICompatibleProviderParserTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
//

import Foundation
import TableProPluginKit
@testable import TablePro
import TableProPluginKit
import Testing

@Suite("OpenAICompatibleProvider stream parser")
Expand Down Expand Up @@ -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()
Expand Down
Loading