diff --git a/README.md b/README.md index 0f5c5a8e..e4194d12 100644 --- a/README.md +++ b/README.md @@ -439,6 +439,49 @@ actor ToolExecutionObserver: ToolExecutionDelegate { session.toolExecutionDelegate = ToolExecutionObserver() ``` +### Reasoning in the transcript + +Reasoning is transcript content, separate from the answer in `response.content`. +A provider can emit `Transcript.Entry.reasoning` through the existing cumulative +`transcriptEntries` on responses and streaming snapshots. Each `Transcript.Reasoning` +contains a stable `id`, display `segments`, opaque `signature: Data?`, and metadata. +Treat successive snapshots as updates to the same entries, not new history rows. + +The built-in Anthropic provider populates these entries for thinking and redacted +thinking, in streaming and nonstreaming responses, including tool rounds: + +```swift +let model = AnthropicLanguageModel(apiKey: apiKey, model: modelID) +let session = LanguageModelSession(model: model) +var options = GenerationOptions(maximumResponseTokens: 4096) +options[custom: AnthropicLanguageModel.self] = .init(thinking: .init(budgetTokens: 1024)) +for try await snapshot in session.streamResponse(to: "Explain your approach", options: options) { + let reasoning = snapshot.transcriptEntries.compactMap { entry -> String? in + guard case .reasoning(let value) = entry else { return nil } + return value.segments.compactMap { segment -> String? in + guard case .text(let text) = segment else { return nil } + return text.content + }.joined() + }.joined() + // Replace the displayed reasoning and answer independently. + print(reasoning) + print(snapshot.content) +} +let savedTranscript = try JSONEncoder().encode(session.transcript) +``` + +Choose an Anthropic model and thinking budget that support this configuration. +Redacted thinking has no display segments. Signatures and metadata are opaque +replay state; preserve them with the transcript, and do not display them as text. +The Anthropic adapter can replay its own reasoning entries after Codable restoration. +When switching providers, adapters that cannot replay reasoning omit those entries +from their requests; Anthropic likewise skips reasoning from other providers. +The original reasoning remains in the transcript for display and persistence. +Anthropic still validates its own replay signatures. CoreML keeps its existing +prompt-only behavior and does not send transcript history. For structured scalar outputs that +cannot represent an absent partial value, reasoning updates wait until a valid +partial answer is available. Cancellation behavior is unchanged. + ### Token Usage Inspect token counts with `response.usage` diff --git a/Sources/AnyLanguageModel/LanguageModelSession.swift b/Sources/AnyLanguageModel/LanguageModelSession.swift index 0a15533a..ad8b5062 100644 --- a/Sources/AnyLanguageModel/LanguageModelSession.swift +++ b/Sources/AnyLanguageModel/LanguageModelSession.swift @@ -1081,7 +1081,7 @@ extension LanguageModelSession { /// The raw content produced so far by the model. public var rawContent: GeneratedContent - /// Transcript entries (tool calls and outputs) produced so far while streaming. + /// Transcript entries (reasoning, tool calls and outputs) produced so far while streaming. /// Cumulative across tool rounds; /// empty for providers that don't stream tool activity. public var transcriptEntries: ArraySlice @@ -1096,7 +1096,7 @@ extension LanguageModelSession { /// - Parameters: /// - content: The partially generated content. /// - rawContent: The raw content produced by the model. - /// - transcriptEntries: Transcript entries accumulated so far (tool calls/outputs). + /// - transcriptEntries: Transcript entries accumulated so far (reasoning/tool calls/outputs). /// - usage: Provider-reported token usage so far. public init( content: Content.PartiallyGenerated, diff --git a/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift b/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift index 9371ba7b..9e8f4356 100644 --- a/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift @@ -202,7 +202,7 @@ public struct AnthropicLanguageModel: LanguageModel { /// How thinking should be returned by the API. /// - /// Thinking content is not currently exposed in session responses or snapshots. + /// Thinking content is exposed as reasoning transcript entries. public var display: ThinkingDisplay? /// The type of thinking mode. @@ -452,7 +452,7 @@ public struct AnthropicLanguageModel: LanguageModel { let params = try createMessageParams( model: model, system: nil, - messages: session.transcript.toAnthropicMessages(), + messages: try session.transcript.toAnthropicMessages(), tools: anthropicTools.isEmpty ? nil : anthropicTools, responseSchema: responseSchema, options: options @@ -467,7 +467,13 @@ public struct AnthropicLanguageModel: LanguageModel { body: body ) - var entries: [Transcript.Entry] = [] + var entries: [Transcript.Entry] = message.content.compactMap { block in + switch block { + case .thinking(let thinking): return .reasoning(thinking.transcriptReasoning()) + case .redactedThinking(let redacted): return .reasoning(redacted.transcriptReasoning()) + default: return nil + } + } let usage = message.usage?.reportedUsage?.value ?? .zero // Handle tool calls, if present @@ -583,7 +589,7 @@ public struct AnthropicLanguageModel: LanguageModel { let responseSchema = type == String.self ? nil : try convertSchemaToAnthropicFormat(schema) - var messages = session.transcript.toAnthropicMessages() + var messages = try session.transcript.toAnthropicMessages() var state = StreamingResponseState() var toolRounds = ToolRoundLimit(provider: "Anthropic") while true { @@ -618,6 +624,10 @@ public struct AnthropicLanguageModel: LanguageModel { switch event { case .contentBlockStart(let start): blocks[start.index] = AnthropicStreamBlock(start.contentBlock) + if let reasoning = blocks[start.index]?.reasoningEntry { + state.entries.append(.reasoning(reasoning)) + if let current = snapshot() { lastSnapshot = current; continuation.yield(current) } + } case .contentBlockDelta(let delta): switch delta.delta { case .textDelta(let textDelta): @@ -631,8 +641,20 @@ public struct AnthropicLanguageModel: LanguageModel { blocks[delta.index]?.arguments += input.partialJson case .thinkingDelta(let thinking): blocks[delta.index]?.thinking += thinking.thinking + if let reasoning = blocks[delta.index]?.reasoningEntry, + let index = state.entries.firstIndex(where: { $0.id == reasoning.id }) + { + state.entries[index] = .reasoning(reasoning) + } + if let current = snapshot() { lastSnapshot = current; continuation.yield(current) } case .signatureDelta(let signature): blocks[delta.index]?.signature += signature.signature + if let reasoning = blocks[delta.index]?.reasoningEntry, + let index = state.entries.firstIndex(where: { $0.id == reasoning.id }) + { + state.entries[index] = .reasoning(reasoning) + } + if let current = snapshot() { lastSnapshot = current; continuation.yield(current) } case .ignored: break } @@ -980,8 +1002,15 @@ private func convertToolToAnthropicFormat(_ tool: any Tool) throws -> AnthropicT // MARK: - Supporting Types extension Transcript { - fileprivate func toAnthropicMessages() -> [AnthropicMessage] { + fileprivate func toAnthropicMessages() throws -> [AnthropicMessage] { var messages = [AnthropicMessage]() + func appendAssistant(_ content: [AnthropicContent]) { + if let last = messages.last, last.role == .assistant { + messages[messages.count - 1] = .init(role: .assistant, content: last.content + content) + } else { + messages.append(.init(role: .assistant, content: content)) + } + } for item in self { switch item { case .instructions(let instructions): @@ -998,13 +1027,29 @@ extension Transcript { content: convertSegmentsToAnthropicContent(prompt.segments) ) ) + case .reasoning(let reasoning): + guard reasoning.metadata["provider"] == GeneratedContent("anthropic") else { + // Foreign reasoning remains display history, not Anthropic replay state. + continue + } + guard let data = reasoning.signature, let signature = String(data: data, encoding: .utf8), + !signature.isEmpty + else { + throw Transcript.ReasoningReplayError.invalidSignature + } + if reasoning.metadata["isRedacted"] == GeneratedContent(true) { + appendAssistant([.redactedThinking(.init(data: signature))]) + continue + } + let text = try reasoning.segments.map { segment -> String in + guard case .text(let text) = segment else { + throw Transcript.ReasoningReplayError.unsupportedProvider("Anthropic reasoning segment") + } + return text.content + }.joined() + appendAssistant([.thinking(.init(thinking: text, signature: signature))]) case .response(let response): - messages.append( - .init( - role: .assistant, - content: convertSegmentsToAnthropicContent(response.segments) - ) - ) + appendAssistant(convertSegmentsToAnthropicContent(response.segments)) case .toolCalls(let toolCalls): // Add assistant message with tool use blocks let toolUseBlocks: [AnthropicContent] = toolCalls.map { call in @@ -1017,12 +1062,7 @@ extension Transcript { ) ) } - messages.append( - .init( - role: .assistant, - content: toolUseBlocks - ) - ) + appendAssistant(toolUseBlocks) case .toolOutput(let toolOutput): // Add user message with tool result messages.append( @@ -1069,11 +1109,13 @@ private enum AnthropicContent: Codable, Sendable { case toolUse(AnthropicToolUse) case toolResult(AnthropicToolResult) case thinking(AnthropicThinking) + case redactedThinking(AnthropicRedactedThinking) enum CodingKeys: String, CodingKey { case type } enum ContentType: String, Codable { - case text = "text", image = "image", toolUse = "tool_use", toolResult = "tool_result", thinking = "thinking" + case text = "text", image = "image", toolUse = "tool_use", toolResult = "tool_result", thinking = "thinking", + redactedThinking = "redacted_thinking" } init(from decoder: any Decoder) throws { @@ -1090,6 +1132,8 @@ private enum AnthropicContent: Codable, Sendable { self = .toolResult(try AnthropicToolResult(from: decoder)) case .thinking: self = .thinking(try AnthropicThinking(from: decoder)) + case .redactedThinking: + self = .redactedThinking(try AnthropicRedactedThinking(from: decoder)) } } @@ -1100,10 +1144,25 @@ private enum AnthropicContent: Codable, Sendable { case .toolUse(let u): try u.encode(to: encoder) case .toolResult(let r): try r.encode(to: encoder) case .thinking(let h): try h.encode(to: encoder) + case .redactedThinking(let value): try value.encode(to: encoder) } } } +private struct AnthropicRedactedThinking: Codable, Sendable { + let type: String + let data: String + init(data: String) { self.type = "redacted_thinking"; self.data = data } + func transcriptReasoning(id: String = UUID().uuidString) -> Transcript.Reasoning { + .init( + id: id, + metadata: ["provider": GeneratedContent("anthropic"), "isRedacted": GeneratedContent(true)], + segments: [], + signature: Data(data.utf8) + ) + } +} + private struct AnthropicThinking: Codable, Sendable { let type: String let thinking: String @@ -1155,6 +1214,17 @@ private struct AnthropicImage: Codable, Sendable { } } +private extension AnthropicThinking { + func transcriptReasoning(id: String = UUID().uuidString) -> Transcript.Reasoning { + .init( + id: id, + metadata: ["provider": GeneratedContent("anthropic")], + segments: [.text(.init(id: id + ":text", content: thinking))], + signature: signature.isEmpty ? nil : Data(signature.utf8) + ) + } +} + private func convertSegmentsToAnthropicContent(_ segments: [Transcript.Segment]) -> [AnthropicContent] { var blocks: [AnthropicContent] = [] blocks.reserveCapacity(segments.count) @@ -1242,6 +1312,14 @@ private struct AnthropicErrorDetail: Codable { // MARK: - Streaming Event Types private struct AnthropicStreamBlock { + let reasoningID = UUID().uuidString + var reasoningEntry: Transcript.Reasoning? { + if start.type == "redacted_thinking", let data = start.data { + return AnthropicRedactedThinking(data: data).transcriptReasoning(id: reasoningID) + } + guard start.type == "thinking" else { return nil } + return AnthropicThinking(thinking: thinking, signature: signature).transcriptReasoning(id: reasoningID) + } let start: AnthropicStreamEvent.ContentBlockStartEvent.ContentBlock var text: String var arguments = "" @@ -1259,6 +1337,9 @@ private struct AnthropicStreamBlock { switch start.type { case "text": return .text(.init(text: text)) case "thinking": return .thinking(.init(thinking: thinking, signature: signature)) + case "redacted_thinking": + guard let data = start.data else { throw Transcript.ReasoningReplayError.invalidSignature } + return .redactedThinking(.init(data: data)) case "tool_use": guard let id = start.id, let name = start.name else { return nil } let input = @@ -1349,6 +1430,7 @@ private enum AnthropicStreamEvent: Codable, Sendable { let input: [String: JSONValue]? let thinking: String? let signature: String? + let data: String? } } diff --git a/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift b/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift index ba06a0c8..94e97d26 100644 --- a/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift @@ -872,6 +872,9 @@ extension Transcript { parts: convertSegmentsToGeminiParts(prompt.segments) ) ) + case .reasoning: + // Keep display history in the transcript without sending unsupported replay state. + continue case .response(let response): messages.append( .init( diff --git a/Sources/AnyLanguageModel/Models/LlamaLanguageModel.swift b/Sources/AnyLanguageModel/Models/LlamaLanguageModel.swift index 68c5ea1d..686215a9 100644 --- a/Sources/AnyLanguageModel/Models/LlamaLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/LlamaLanguageModel.swift @@ -2320,6 +2320,9 @@ import Foundation messages.append(("user", text)) } + case .reasoning: + // Keep display history in the transcript without sending unsupported replay state. + return case .response(let response): let text = try extractContent( from: response.segments, diff --git a/Sources/AnyLanguageModel/Models/MLXLanguageModel.swift b/Sources/AnyLanguageModel/Models/MLXLanguageModel.swift index c8a2d780..fd574ac9 100644 --- a/Sources/AnyLanguageModel/Models/MLXLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/MLXLanguageModel.swift @@ -1561,6 +1561,9 @@ import Foundation case .prompt(let prompt): chat.append(makeMLXChatMessage(from: prompt.segments, role: .user)) + case .reasoning: + // Keep display history in the transcript without sending unsupported replay state. + continue case .response(let response): let content = response.segments.map { extractText(from: $0) }.joined(separator: "\n") chat.append(.assistant(content)) diff --git a/Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift b/Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift index fcbb7f11..29f025c6 100644 --- a/Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift @@ -602,7 +602,7 @@ struct OllamaMessage: Hashable, Codable, Sendable { private extension Transcript { func toOllamaMessages() throws -> [OllamaMessage] { - try map { entry in + try compactMap { entry -> OllamaMessage? in let role: OllamaMessage.Role let segments: [Transcript.Segment] switch entry { @@ -612,6 +612,9 @@ private extension Transcript { case .prompt(let prompt): role = .user segments = prompt.segments + case .reasoning: + // Keep display history in the transcript without sending unsupported replay state. + return nil case .response(let response): role = .assistant segments = response.segments diff --git a/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift b/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift index ac297f9f..5498d644 100644 --- a/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift @@ -1338,6 +1338,9 @@ extension Transcript { content: .blocks(convertSegmentsToOpenAIBlocks(prompt.segments)) ) ) + case .reasoning: + // Keep display history in the transcript without sending unsupported replay state. + continue case .response(let response): messages.append( .init( diff --git a/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift b/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift index 82dfb46a..265e063b 100644 --- a/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift @@ -922,6 +922,9 @@ extension Transcript { content: .blocks(openResponsesConvertSegmentsToBlocks(prompt.segments)) ) ) + case .reasoning: + // Keep display history in the transcript without sending unsupported replay state. + continue case .response(let response): list.append( OpenResponsesMessage( diff --git a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift index d4f10119..64adfc6c 100644 --- a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift @@ -606,6 +606,9 @@ ) fmEntries.append(.prompt(fmPrompt)) + case .reasoning: + // Keep display history in the transcript without sending unsupported replay state. + continue case .response(let response): let fmSegments = response.segments.toFoundationModels() let fmResponse = FoundationModels.Transcript.Response( diff --git a/Sources/AnyLanguageModel/Transcript.swift b/Sources/AnyLanguageModel/Transcript.swift index 05abfecd..b2098663 100644 --- a/Sources/AnyLanguageModel/Transcript.swift +++ b/Sources/AnyLanguageModel/Transcript.swift @@ -36,6 +36,9 @@ public struct Transcript: Sendable, Equatable, Codable { /// An tool output provided back to the model. case toolOutput(ToolOutput) + /// Provider reasoning, separate from the person-facing response. + case reasoning(Reasoning) + /// A response from the model. case response(Response) @@ -50,6 +53,8 @@ public struct Transcript: Sendable, Equatable, Codable { return toolCalls.id case .toolOutput(let toolOutput): return toolOutput.id + case .reasoning(let reasoning): + return reasoning.id case .response(let response): return response.id } @@ -384,6 +389,32 @@ public struct Transcript: Sendable, Equatable, Codable { } } + /// A provider cannot safely replay a reasoning entry in this transcript. + public enum ReasoningReplayError: Error, Sendable, Equatable { + case unsupportedProvider(String) + case invalidSignature + } + + /// Model reasoning and opaque state needed to continue a conversation. + public struct Reasoning: Sendable, Identifiable, Equatable, Codable { + public var id: String + public var segments: [Segment] + public var signature: Data? + public var metadata: [String: GeneratedContent] + + public init( + id: String = UUID().uuidString, + metadata: [String: GeneratedContent] = [:], + segments: [Segment], + signature: Data? = nil + ) { + self.id = id + self.segments = segments + self.signature = signature + self.metadata = metadata + } + } + /// A response from the model. public struct Response: Sendable, Identifiable, Equatable, Codable { /// The stable identity of the entity associated with this instance. @@ -456,6 +487,8 @@ extension Transcript.Entry: CustomStringConvertible { return "toolCalls(\(toolCalls))" case .toolOutput(let toolOutput): return "toolOutput(\(toolOutput))" + case .reasoning(let reasoning): + return "reasoning(segments: \(reasoning.segments.count))" case .response(let response): return "response(\(response))" } diff --git a/Tests/AnyLanguageModelTests/AnthropicReasoningTests.swift b/Tests/AnyLanguageModelTests/AnthropicReasoningTests.swift new file mode 100644 index 00000000..8506ba9e --- /dev/null +++ b/Tests/AnyLanguageModelTests/AnthropicReasoningTests.swift @@ -0,0 +1,399 @@ +import Foundation +import Testing + +@testable import AnyLanguageModel + +#if canImport(Darwin) && !canImport(AsyncHTTPClient) + @Suite("Anthropic reasoning replay", .serialized) + struct AnthropicReasoningTests { + private func model() -> AnthropicLanguageModel { + .init(apiKey: "fixture", model: "fixture", session: ReasoningURLProtocol.makeSession()) + } + + @Test(arguments: [false, true]) + func thinkingSurvivesPersistenceAndReplaysWithAnswer(streaming: Bool) async throws { + ReasoningURLProtocol.reset() + let session = LanguageModelSession(model: model()) + let result: LanguageModelSession.Response + if streaming { + ReasoningURLProtocol.enqueue(eventStream: events()) + var ids: Set = [] + var snapshots: [LanguageModelSession.ResponseStream.Snapshot] = [] + for try await snapshot in session.streamResponse(to: "Question") { + snapshots.append(snapshot) + if let entry = snapshot.transcriptEntries.first { ids.insert(entry.id) } + let visible: String = snapshot.content + #expect(!visible.contains("Consider")) + } + #expect(ids.count == 1) + #expect(snapshots.contains { $0.content.isEmpty && !$0.transcriptEntries.isEmpty }) + result = try await LanguageModelSession.ResponseStream( + stream: AsyncThrowingStream { + $0.yield(snapshots.last!); $0.finish() + } + ).collect() + } else { + ReasoningURLProtocol.enqueue(json: response()) + result = try await session.respond(to: "Question") + } + #expect(result.content == "Answer") + let entry = try #require(result.transcriptEntries.first) + guard case .reasoning(let reasoning) = entry else { Issue.record("Missing reasoning"); return } + #expect(reasoning.segments.first?.description == "Consider") + #expect(reasoning.signature == Data("opaque-signature".utf8)) + let restored = try JSONDecoder().decode(Transcript.self, from: JSONEncoder().encode(session.transcript)) + ReasoningURLProtocol.enqueue(json: response()) + _ = try await LanguageModelSession(model: model(), transcript: restored).respond(to: "Again") + let body = try #require(ReasoningURLProtocol.recordedBodies.last) + let json = try JSONSerialization.jsonObject(with: body) as! [String: Any] + let messages = json["messages"] as! [[String: Any]] + let assistant = try #require(messages.first { $0["role"] as? String == "assistant" }) + let content = assistant["content"] as! [[String: Any]] + #expect(content.map { $0["type"] as! String } == ["thinking", "text"]) + #expect(content[0]["signature"] as? String == "opaque-signature") + #expect(content[1]["text"] as? String == "Answer") + } + + @Test(arguments: [false, true]) + func thinkingAndCompletedToolPersistForProviderToolFlow(streaming: Bool) async throws { + ReasoningURLProtocol.reset() + let session = LanguageModelSession(model: model(), tools: [WeatherTool()]) + let result: LanguageModelSession.Response + if streaming { + ReasoningURLProtocol.enqueue(eventStream: events(tool: true)) + ReasoningURLProtocol.enqueue(eventStream: events()) + result = try await session.streamResponse(to: "Weather").collect() + } else { + ReasoningURLProtocol.enqueue(json: response(tool: true)) + result = try await session.respond(to: "Weather") + } + #expect(result.content == (streaming ? "Answer" : "")) + #expect(result.transcriptEntries.count == (streaming ? 4 : 3)) + #expect(Set(result.transcriptEntries.map(\.id)).count == result.transcriptEntries.count) + #expect(ReasoningURLProtocol.recordedBodies.count == (streaming ? 2 : 1)) + if streaming { + let body = String(decoding: ReasoningURLProtocol.recordedBodies[1], as: UTF8.self) + #expect(body.contains("opaque-signature")) + #expect(body.contains("tool_result")) + } else { + // Nonstreaming Anthropic keeps its existing one-request tool behavior. + let restored = try JSONDecoder().decode(Transcript.self, from: JSONEncoder().encode(session.transcript)) + ReasoningURLProtocol.enqueue(json: response()) + _ = try await LanguageModelSession(model: model(), tools: [WeatherTool()], transcript: restored) + .respond(to: "Continue") + let body = String(decoding: ReasoningURLProtocol.recordedBodies[1], as: UTF8.self) + #expect(body.contains("opaque-signature")) + #expect(body.contains("tool_result")) + } + } + + @Test(arguments: [false, true]) + func redactedThinkingNeverBecomesDisplayText(streaming: Bool) async throws { + ReasoningURLProtocol.reset() + let session = LanguageModelSession(model: model()) + if streaming { + ReasoningURLProtocol.enqueue(eventStream: [ + #"{"type":"content_block_start","index":0,"content_block":{"type":"redacted_thinking","data":"opaque-redacted"}}"#, + #"{"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}"#, + #"{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"Answer"}}"#, + #"{"type":"message_stop"}"#, + ]) + _ = try await session.streamResponse(to: "Question").collect() + } else { + ReasoningURLProtocol.enqueue( + json: + #"{"id":"msg","type":"message","role":"assistant","model":"fixture","content":[{"type":"redacted_thinking","data":"opaque-redacted"},{"type":"text","text":"Answer"}],"stop_reason":"end_turn"}"# + ) + _ = try await session.respond(to: "Question") + } + guard case .reasoning(let reasoning) = session.transcript[1] else { + Issue.record("Missing redacted state"); return + } + #expect(reasoning.segments.isEmpty) + #expect(reasoning.signature == Data("opaque-redacted".utf8)) + ReasoningURLProtocol.enqueue(json: response()) + _ = try await session.respond(to: "Next") + let json = + try JSONSerialization.jsonObject(with: ReasoningURLProtocol.recordedBodies.last!) as! [String: Any] + let messages = json["messages"] as! [[String: Any]] + let blocks = messages.first { $0["role"] as? String == "assistant" }!["content"] as! [[String: Any]] + #expect(blocks.first?["type"] as? String == "redacted_thinking") + #expect(blocks.first?["data"] as? String == "opaque-redacted") + } + + @Test(arguments: [false, true], [false, true]) + func foreignReasoningIsSkippedWithoutChangingHistory(streaming: Bool, missingProvider: Bool) async throws { + ReasoningURLProtocol.reset() + let original = Transcript(entries: [ + .prompt(.init(segments: [.text(.init(content: "Earlier question"))])), + .reasoning( + .init( + metadata: missingProvider ? [:] : ["provider": GeneratedContent("other")], + segments: [.text(.init(content: "Foreign reasoning"))], + signature: Data("foreign-signature".utf8) + ) + ), + .response(.init(assetIDs: [], segments: [.text(.init(content: "Earlier answer"))])), + ]) + let restored = try JSONDecoder().decode(Transcript.self, from: JSONEncoder().encode(original)) + let session = LanguageModelSession(model: model(), transcript: restored) + if streaming { + ReasoningURLProtocol.enqueue(eventStream: events()) + } else { + ReasoningURLProtocol.enqueue(json: response()) + } + let result = + try await streaming ? session.streamResponse(to: "Next").collect() : session.respond(to: "Next") + #expect(result.content == "Answer") + #expect(ReasoningURLProtocol.recordedBodies.count == 1) + let body = String(decoding: try #require(ReasoningURLProtocol.recordedBodies.first), as: UTF8.self) + #expect(body.contains("Earlier question")) + #expect(body.contains("Earlier answer")) + #expect(!body.contains("Foreign reasoning")) + #expect(!body.contains("foreign-signature")) + #expect(Array(session.transcript.prefix(original.count)) == Array(original)) + } + + @Test(arguments: ["chat", "responses", "open-responses", "gemini", "ollama"], [false, true]) + func otherProviderRequestOmitsReasoningAndPreservesCodableHistory(provider: String, streaming: Bool) + async throws + { + ReasoningURLProtocol.reset() + let original = Transcript(entries: [ + .prompt(.init(segments: [.text(.init(content: "Earlier question"))])), + .reasoning( + .init( + metadata: ["provider": GeneratedContent("anthropic")], + segments: [.text(.init(content: "Private reasoning"))], + signature: Data("opaque-secret".utf8) + ) + ), + .response(.init(assetIDs: [], segments: [.text(.init(content: "Earlier answer"))])), + ]) + let restored = try JSONDecoder().decode(Transcript.self, from: JSONEncoder().encode(original)) + let transport = ReasoningURLProtocol.makeSession() + let endpoint = URL(string: "https://fixture.invalid/v1/")! + let providerModel: any LanguageModel + switch provider { + case "chat", "responses": + providerModel = OpenAILanguageModel( + baseURL: endpoint, + apiKey: "fixture", + model: "fixture", + apiVariant: provider == "chat" ? .chatCompletions : .responses, + session: transport + ) + case "open-responses": + providerModel = OpenResponsesLanguageModel( + baseURL: endpoint, + apiKey: "fixture", + model: "fixture", + session: transport + ) + case "gemini": + providerModel = GeminiLanguageModel( + baseURL: endpoint, + apiKey: "fixture", + model: "fixture", + session: transport + ) + default: providerModel = OllamaLanguageModel(baseURL: endpoint, model: "fixture", session: transport) + } + let session = LanguageModelSession(model: providerModel, transcript: restored) + // A fixed HTTP failure isolates request projection from each provider's response decoder. + ReasoningURLProtocol.enqueue(json: #"{"error":{"message":"fixture rejection"}}"#, statusCode: 418) + do { + if streaming { + _ = try await session.streamResponse(to: "Next").collect() + } else { + _ = try await session.respond(to: "Next") + } + Issue.record("Expected fixture HTTP failure") + } catch { + #expect(!(error is Transcript.ReasoningReplayError)) + } + #expect(ReasoningURLProtocol.recordedBodies.count == 1) + let body = String(decoding: try #require(ReasoningURLProtocol.recordedBodies.first), as: UTF8.self) + #expect(body.contains("Next")) + // Compare against this adapter's existing history projection. Some adapters + // intentionally project only the prompt or structured response metadata. + let withoutReasoning = Transcript( + entries: original.filter { + if case .reasoning = $0 { return false } + return true + } + ) + let baseline = LanguageModelSession(model: providerModel, transcript: withoutReasoning) + ReasoningURLProtocol.enqueue(json: #"{"error":{"message":"fixture rejection"}}"#, statusCode: 418) + do { + if streaming { + _ = try await baseline.streamResponse(to: "Next").collect() + } else { + _ = try await baseline.respond(to: "Next") + } + Issue.record("Expected fixture HTTP failure") + } catch {} + #expect(ReasoningURLProtocol.recordedBodies.count == 2) + let baselineBody = try #require(ReasoningURLProtocol.recordedBodies.last) + let actualJSON = try JSONSerialization.jsonObject(with: Data(body.utf8)) as! NSDictionary + let baselineJSON = try JSONSerialization.jsonObject(with: baselineBody) as! NSDictionary + #expect(actualJSON == baselineJSON) + #expect(!body.contains("Private reasoning")) + #expect(!body.contains("opaque-secret")) + #expect(Array(session.transcript.prefix(original.count)) == Array(original)) + let saved = try JSONDecoder().decode(Transcript.self, from: JSONEncoder().encode(session.transcript)) + #expect(Array(saved.prefix(original.count)) == Array(original)) + } + + @Test(arguments: [false, true]) + func nativeReasoningStillRejectsMissingSignature(streaming: Bool) async throws { + ReasoningURLProtocol.reset() + let transcript = Transcript(entries: [ + .reasoning( + .init( + metadata: ["provider": GeneratedContent("anthropic")], + segments: [.text(.init(content: "Native reasoning"))] + ) + ) + ]) + let session = LanguageModelSession(model: model(), transcript: transcript) + await #expect(throws: Transcript.ReasoningReplayError.invalidSignature) { + if streaming { + _ = try await session.streamResponse(to: "Next").collect() + } else { + _ = try await session.respond(to: "Next") + } + } + #expect(ReasoningURLProtocol.recordedBodies.isEmpty) + #expect(Array(session.transcript.prefix(transcript.count)) == Array(transcript)) + } + + private func response(tool: Bool = false) -> String { + let last = + tool + ? #"{"type":"tool_use","id":"call","name":"getWeather","input":{"city":"Paris"}}"# + : #"{"type":"text","text":"Answer"}"# + return + #"{"id":"msg","type":"message","role":"assistant","model":"fixture","content":[{"type":"thinking","thinking":"Consider","signature":"opaque-signature"},"# + + last + #"],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":2}}"# + } + + private func events(tool: Bool = false) -> [String] { + var result = [ + #"{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":"","signature":""}}"#, + #"{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"Consider"}}"#, + #"{"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"opaque-signature"}}"#, + #"{"type":"content_block_stop","index":0}"#, + ] + if tool { + result.append( + #"{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"call","name":"getWeather","input":{"city":"Paris"}}}"# + ) + } else { + result.append(#"{"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}"#) + result.append( + #"{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"Answer"}}"# + ) + } + result.append(#"{"type":"message_stop"}"#) + return result + } + } +#endif + +#if canImport(Darwin) && !canImport(AsyncHTTPClient) + + /// A `URLProtocol` that answers requests from a queue of canned responses and records + /// every request body it sees, so request/response round trips can be asserted offline. + final class ReasoningURLProtocol: URLProtocol { + struct Exchange: Sendable { + var statusCode: Int = 200 + var body: Data + var contentType: String = "application/json" + } + + private struct State: Sendable { + var pending: [Exchange] = [] + var recordedBodies: [Data] = [] + } + + private static let state = Locked(State()) + + /// Discards queued responses and recorded bodies. + static func reset() { + state.withLock { $0 = State() } + } + + /// Queues one JSON response, returned to the next request that arrives. + static func enqueue(json: String, statusCode: Int = 200) { + state.withLock { $0.pending.append(Exchange(statusCode: statusCode, body: Data(json.utf8))) } + } + + static func enqueue(eventStream: [String]) { + let body = eventStream.map { "data: \($0)\n\n" }.joined() + state.withLock { + $0.pending.append(Exchange(body: Data(body.utf8), contentType: "text/event-stream")) + } + } + + /// The bodies of the requests seen so far, in order. + static var recordedBodies: [Data] { + state.withLock { $0.recordedBodies } + } + + /// A session that routes every request to this protocol. + static func makeSession() -> URLSession { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [ReasoningURLProtocol.self] + return URLSession(configuration: configuration) + } + + override class func canInit(with request: URLRequest) -> Bool { true } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + // URLSession moves `httpBody` to `httpBodyStream` before the protocol sees the request. + let body = request.httpBody ?? request.httpBodyStream.map(Self.readAll) ?? Data() + + let exchange = Self.state.withLock { state -> Exchange? in + state.recordedBodies.append(body) + return state.pending.isEmpty ? nil : state.pending.removeFirst() + } + + guard let exchange, let url = request.url else { + client?.urlProtocol(self, didFailWithError: URLError(.resourceUnavailable)) + return + } + + let response = HTTPURLResponse( + url: url, + statusCode: exchange.statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": exchange.contentType] + )! + + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: exchange.body) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} + + private static func readAll(_ stream: InputStream) -> Data { + stream.open() + defer { stream.close() } + + var data = Data() + let bufferSize = 4096 + var buffer = [UInt8](repeating: 0, count: bufferSize) + while true { + let read = stream.read(&buffer, maxLength: bufferSize) + if read <= 0 { break } + data.append(buffer, count: read) + } + return data + } + } + +#endif diff --git a/Tests/AnyLanguageModelTests/ReasoningTests.swift b/Tests/AnyLanguageModelTests/ReasoningTests.swift new file mode 100644 index 00000000..b66fca34 --- /dev/null +++ b/Tests/AnyLanguageModelTests/ReasoningTests.swift @@ -0,0 +1,118 @@ +import Foundation +import Testing + +@testable import AnyLanguageModel + +@Suite("Reasoning transcript") +struct ReasoningTests { + @Test func codablePreservesOpaqueSignatureAndMetadata() throws { + let reasoning = Transcript.Reasoning( + id: "reason", + metadata: ["provider": GeneratedContent("fixture"), "redacted": GeneratedContent(true)], + segments: [.text(.init(id: "segment", content: "Summary"))], + signature: Data([0, 255, 16]) + ) + let transcript = Transcript(entries: [.reasoning(reasoning)]) + #expect(try JSONDecoder().decode(Transcript.self, from: JSONEncoder().encode(transcript)) == transcript) + #expect(transcript.first?.id == "reason") + #expect(!transcript.first!.description.contains("255")) + } + + @Test func originalInitializerFunctionReferencesCompile() async throws { + guard #available(macOS 26.0, iOS 26.0, watchOS 27.0, *) else { return } + let responseInit = LanguageModelSession.Response.init(content:rawContent:transcriptEntries:usage:) + let snapshotInit = LanguageModelSession.ResponseStream.Snapshot.init( + content: + rawContent: + transcriptEntries: + usage: + ) + let streamInit = LanguageModelSession.ResponseStream.init(content:rawContent:usage:) + let raw = GeneratedContent("Answer") + #expect(responseInit("Answer", raw, [], .zero).content == "Answer") + #expect(snapshotInit("Answer", raw, [], .zero).content == "Answer") + #expect(try await streamInit("Answer", raw, .zero).collect().content == "Answer") + } + + @Test func cumulativeEntriesPersistOnceAndDoNotPolluteAnswer() async throws { + let session = LanguageModelSession(model: ReasoningModel()) + var ids: [String] = [] + for try await snapshot in session.streamResponse(to: "Question") { + ids.append(try #require(snapshot.transcriptEntries.first?.id)) + let visible: String = snapshot.content + #expect(!visible.contains("First")) + } + #expect(Set(ids) == ["reason"]) + #expect(session.transcript.count == 3) + guard case .reasoning(let reasoning) = session.transcript[1] else { Issue.record("Missing reasoning"); return } + #expect(reasoning.segments == [.text(.init(id: "segment", content: "First second"))]) + guard case .response(let answer) = session.transcript[2] else { Issue.record("Missing answer"); return } + #expect(answer.segments.count == 1) + #expect(answer.segments.first?.description == "Answer") + } + + @Test func nonstreamAndSchemaRetainEntries() async throws { + let session = LanguageModelSession(model: ReasoningModel()) + let response = try await session.respond(to: "Question", schema: String.generationSchema) + #expect(response.transcriptEntries.count == 1) + #expect(response.rawContent == GeneratedContent("Answer")) + let streamed = try await session.streamResponse(to: "Again", schema: String.generationSchema).collect() + #expect(streamed.transcriptEntries.count == 1) + #expect(streamed.rawContent == GeneratedContent("Answer")) + } + +} + +private struct ReasoningModel: LanguageModel { + typealias UnavailableReason = Never + + func respond( + within session: LanguageModelSession, + to prompt: Prompt, + generating type: Content.Type, + includeSchemaInPrompt: Bool, + options: GenerationOptions + ) async throws -> LanguageModelSession.Response { + try await streamResponse( + within: session, + to: prompt, + generating: type, + includeSchemaInPrompt: includeSchemaInPrompt, + options: options + ).collect() + } + + func streamResponse( + within session: LanguageModelSession, + to prompt: Prompt, + generating type: Content.Type, + includeSchemaInPrompt: Bool, + options: GenerationOptions + ) -> sending LanguageModelSession.ResponseStream { + .init( + stream: AsyncThrowingStream { continuation in + do { + for (text, reasoning) in [("", "First"), ("", "First second"), ("Answer", "First second")] { + let raw = GeneratedContent(text) + let entries: [Transcript.Entry] = [ + .reasoning( + .init( + id: "reason", + segments: [.text(.init(id: "segment", content: reasoning))] + ) + ) + ] + continuation.yield( + .init( + content: try Content(raw).asPartiallyGenerated(), + rawContent: raw, + transcriptEntries: ArraySlice(entries) + ) + ) + } + continuation.finish() + } catch { continuation.finish(throwing: error) } + } + ) + } +}