Skip to content

Commit 5f70662

Browse files
authored
fix(ai-chat): capture reasoning content during streaming for DeepSeek V4, resolving 400 errors (#1269)
1 parent 221678a commit 5f70662

4 files changed

Lines changed: 213 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2626

2727
### Fixed
2828

29+
- 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.
2930
- 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.
3031
- 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)
3132
- SQL import ignored the database dialect, so PostgreSQL dumps with dollar-quoted function bodies were split at semicolons inside the body. (#1264)

TablePro/Core/AI/OpenAICompatibleProvider.swift

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ final class OpenAICompatibleProvider: ChatTransport {
7171
for event in result.events { continuation.yield(event) }
7272
if result.shouldBreak { break }
7373
}
74+
if let reasoningEnd = state.flushReasoningEnd() {
75+
continuation.yield(reasoningEnd)
76+
}
7477
if let usage = state.finalUsageEvent() {
7578
continuation.yield(usage)
7679
}
@@ -113,6 +116,19 @@ final class OpenAICompatibleProvider: ChatTransport {
113116
let firstChoice = choices?.first
114117
let delta = firstChoice?["delta"] as? [String: Any]
115118

119+
if let delta, let reasoningContent = delta["reasoning_content"] as? String, !reasoningContent.isEmpty {
120+
let reasoningID: String
121+
if let existing = state.reasoningBlockID {
122+
reasoningID = existing
123+
} else {
124+
let newID = "reasoning_\(UUID().uuidString.prefix(8))"
125+
state.reasoningBlockID = newID
126+
events.append(.reasoningStart(id: newID))
127+
reasoningID = newID
128+
}
129+
events.append(.reasoningDelta(id: reasoningID, text: reasoningContent))
130+
}
131+
116132
if let delta, let content = delta["content"] as? String, !content.isEmpty {
117133
events.append(.textDelta(content))
118134
} else if let message = json["message"] as? [String: Any],
@@ -128,9 +144,13 @@ final class OpenAICompatibleProvider: ChatTransport {
128144
events.append(contentsOf: handleOllamaToolCalls(toolCalls, state: &state))
129145
}
130146

131-
if let finishReason = firstChoice?["finish_reason"] as? String,
132-
finishReason == "tool_calls" {
133-
events.append(contentsOf: state.flushToolUseEnds())
147+
if let finishReason = firstChoice?["finish_reason"] as? String, !finishReason.isEmpty {
148+
if let event = state.flushReasoningEnd() {
149+
events.append(event)
150+
}
151+
if finishReason == "tool_calls" {
152+
events.append(contentsOf: state.flushToolUseEnds())
153+
}
134154
}
135155

136156
if let usage = json["usage"] as? [String: Any],
@@ -373,6 +393,10 @@ final class OpenAICompatibleProvider: ChatTransport {
373393
]
374394
]
375395
}
396+
let reasoningText = Self.plainReasoningText(from: turn)
397+
if !reasoningText.isEmpty {
398+
message["reasoning_content"] = reasoningText
399+
}
376400
return [message]
377401
}
378402

@@ -411,10 +435,26 @@ final class OpenAICompatibleProvider: ChatTransport {
411435
}
412436

413437
guard !textContent.isEmpty else { return [] }
414-
return [[
438+
var message: [String: Any] = [
415439
"role": turn.role.rawValue,
416440
"content": textContent
417-
]]
441+
]
442+
if turn.role == .assistant {
443+
let reasoningText = Self.plainReasoningText(from: turn)
444+
if !reasoningText.isEmpty {
445+
message["reasoning_content"] = reasoningText
446+
}
447+
}
448+
return [message]
449+
}
450+
451+
private static func plainReasoningText(from turn: ChatTurnWire) -> String {
452+
turn.blocks.compactMap { block -> String? in
453+
guard case .reasoning(let rb) = block.kind,
454+
rb.opaque == nil,
455+
let text = rb.text else { return nil }
456+
return text
457+
}.joined()
418458
}
419459

420460
private func chatCompletionsImagePart(_ input: ChatImageInput) -> [String: Any]? {
@@ -523,6 +563,13 @@ struct OpenAIStreamState {
523563
var outputTokens: Int = 0
524564
var toolCallIndexToId: [Int: String] = [:]
525565
var toolCallOrder: [Int] = []
566+
var reasoningBlockID: String?
567+
568+
mutating func flushReasoningEnd() -> ChatStreamEvent? {
569+
guard let id = reasoningBlockID else { return nil }
570+
reasoningBlockID = nil
571+
return .reasoningEnd(id: id, opaque: nil)
572+
}
526573

527574
/// Yield `.toolUseEnd` for every tracked tool call and clear the map.
528575
/// Called when the provider signals tool-call completion (`finish_reason`

TableProTests/Core/AI/OpenAICompatibleProviderEncodingTests.swift

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,59 @@ struct OpenAICompatibleProviderEncodingTests {
9292
#expect(messages[1]["tool_call_id"] as? String == "call_2")
9393
}
9494

95+
@Test("Assistant turn with plain reasoning block includes reasoning_content field")
96+
func assistantWithPlainReasoning() {
97+
let reasoning = ReasoningBlock(text: "I think about this step by step", opaque: nil)
98+
let turn = ChatTurnWire(role: .assistant, blocks: [
99+
.reasoning(reasoning),
100+
.text("Here is my answer")
101+
])
102+
let messages = makeProvider().encodeTurn(turn)
103+
#expect(messages.count == 1)
104+
#expect(messages[0]["role"] as? String == "assistant")
105+
#expect(messages[0]["content"] as? String == "Here is my answer")
106+
#expect(messages[0]["reasoning_content"] as? String == "I think about this step by step")
107+
}
108+
109+
@Test("Assistant turn with Anthropic-signed reasoning does not include reasoning_content")
110+
func assistantWithAnthropicReasoningOmitsField() {
111+
let opaque = ReasoningOpaque(kind: .anthropicSignature, itemID: "blk_1", value: "sig123", blockType: "thinking")
112+
let reasoning = ReasoningBlock(text: "hidden thinking", opaque: opaque)
113+
let turn = ChatTurnWire(role: .assistant, blocks: [
114+
.reasoning(reasoning),
115+
.text("My answer")
116+
])
117+
let messages = makeProvider().encodeTurn(turn)
118+
#expect(messages.count == 1)
119+
#expect(messages[0]["reasoning_content"] == nil)
120+
}
121+
122+
@Test("Assistant turn with tool calls and plain reasoning includes reasoning_content")
123+
func assistantWithToolCallsAndReasoning() {
124+
let reasoning = ReasoningBlock(text: "need to check tables", opaque: nil)
125+
let toolUse = ToolUseBlock(id: "call_1", name: "list_tables", input: .object([:]))
126+
let turn = ChatTurnWire(role: .assistant, blocks: [
127+
.reasoning(reasoning),
128+
.toolUse(toolUse)
129+
])
130+
let messages = makeProvider().encodeTurn(turn)
131+
#expect(messages.count == 1)
132+
#expect(messages[0]["tool_calls"] != nil)
133+
#expect(messages[0]["reasoning_content"] as? String == "need to check tables")
134+
}
135+
136+
@Test("User turn never includes reasoning_content even with reasoning blocks")
137+
func userTurnWithReasoningOmitsField() {
138+
let reasoning = ReasoningBlock(text: "user reasoning", opaque: nil)
139+
let turn = ChatTurnWire(role: .user, blocks: [
140+
.reasoning(reasoning),
141+
.text("my question")
142+
])
143+
let messages = makeProvider().encodeTurn(turn)
144+
#expect(messages.count == 1)
145+
#expect(messages[0]["reasoning_content"] == nil)
146+
}
147+
95148
@Test("Empty text turn returns no messages")
96149
func emptyTurnYieldsNothing() {
97150
let turn = ChatTurnWire(role: .user, blocks: [.text("")])

TableProTests/Core/AI/OpenAICompatibleProviderParserTests.swift

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
//
55

66
import Foundation
7-
import TableProPluginKit
87
@testable import TablePro
8+
import TableProPluginKit
99
import Testing
1010

1111
@Suite("OpenAICompatibleProvider stream parser")
@@ -175,6 +175,112 @@ struct OpenAICompatibleProviderParserTests {
175175
#expect(text == "hi")
176176
}
177177

178+
@Test("delta.reasoning_content on first chunk emits reasoningStart then reasoningDelta")
179+
func reasoningContentFirstChunk() {
180+
var state = OpenAIStreamState()
181+
let result = OpenAICompatibleProvider.parseChunk([
182+
"choices": [[
183+
"delta": ["reasoning_content": "Let me think..."]
184+
]]
185+
], state: &state)
186+
#expect(result.events.count == 2)
187+
guard case .reasoningStart(let id) = result.events[0] else {
188+
Issue.record("expected reasoningStart; got \(result.events[0])")
189+
return
190+
}
191+
guard case .reasoningDelta(let deltaID, let text) = result.events[1] else {
192+
Issue.record("expected reasoningDelta; got \(result.events[1])")
193+
return
194+
}
195+
#expect(deltaID == id)
196+
#expect(text == "Let me think...")
197+
#expect(state.reasoningBlockID == id)
198+
}
199+
200+
@Test("Subsequent delta.reasoning_content chunks emit only reasoningDelta (no duplicate start)")
201+
func reasoningContentSubsequentChunk() {
202+
var state = OpenAIStreamState()
203+
state.reasoningBlockID = "reasoning_abc"
204+
let result = OpenAICompatibleProvider.parseChunk([
205+
"choices": [[
206+
"delta": ["reasoning_content": " more thinking"]
207+
]]
208+
], state: &state)
209+
#expect(result.events.count == 1)
210+
guard case .reasoningDelta(let id, let text) = result.events[0] else {
211+
Issue.record("expected reasoningDelta; got \(result.events)")
212+
return
213+
}
214+
#expect(id == "reasoning_abc")
215+
#expect(text == " more thinking")
216+
}
217+
218+
@Test("finish_reason: stop flushes open reasoning block as reasoningEnd with nil opaque")
219+
func finishReasonStopClosesReasoningBlock() {
220+
var state = OpenAIStreamState()
221+
state.reasoningBlockID = "reasoning_xyz"
222+
let result = OpenAICompatibleProvider.parseChunk([
223+
"choices": [["finish_reason": "stop"]]
224+
], state: &state)
225+
#expect(result.events.count == 1)
226+
guard case .reasoningEnd(let id, let opaque) = result.events[0] else {
227+
Issue.record("expected reasoningEnd; got \(result.events)")
228+
return
229+
}
230+
#expect(id == "reasoning_xyz")
231+
#expect(opaque == nil)
232+
#expect(state.reasoningBlockID == nil)
233+
}
234+
235+
@Test("reasoning_content followed by finish_reason in same chunk emits start, delta, end")
236+
func reasoningContentWithFinishReason() {
237+
var state = OpenAIStreamState()
238+
let result = OpenAICompatibleProvider.parseChunk([
239+
"choices": [[
240+
"delta": ["reasoning_content": "final thought"],
241+
"finish_reason": "stop"
242+
]]
243+
], state: &state)
244+
let kinds = result.events.map { event -> String in
245+
switch event {
246+
case .reasoningStart: return "start"
247+
case .reasoningDelta: return "delta"
248+
case .reasoningEnd: return "end"
249+
default: return "other"
250+
}
251+
}
252+
#expect(kinds == ["start", "delta", "end"])
253+
#expect(state.reasoningBlockID == nil)
254+
}
255+
256+
@Test("delta.reasoning_content: null is ignored and does not emit reasoningStart")
257+
func reasoningContentNullIgnored() {
258+
var state = OpenAIStreamState()
259+
let result = OpenAICompatibleProvider.parseChunk([
260+
"choices": [[
261+
"delta": ["content": "hello", "reasoning_content": NSNull()]
262+
]]
263+
], state: &state)
264+
#expect(state.reasoningBlockID == nil)
265+
#expect(result.events.count == 1)
266+
guard case .textDelta = result.events[0] else {
267+
Issue.record("expected only textDelta; got \(result.events)")
268+
return
269+
}
270+
}
271+
272+
@Test("finish_reason: stop does not flush pending tool calls")
273+
func finishReasonStopLeavesToolCallsIntact() {
274+
var state = OpenAIStreamState()
275+
state.toolCallIndexToId = [0: "call_a"]
276+
state.toolCallOrder = [0]
277+
let result = OpenAICompatibleProvider.parseChunk([
278+
"choices": [["finish_reason": "stop"]]
279+
], state: &state)
280+
#expect(result.events.isEmpty)
281+
#expect(state.toolCallIndexToId[0] == "call_a")
282+
}
283+
178284
@Test("Empty chunk yields no events and doesn't break")
179285
func emptyChunk() {
180286
var state = OpenAIStreamState()

0 commit comments

Comments
 (0)