From 9265b9d8b8d3ffbf1d28cbf5ad147105a8198f11 Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Fri, 25 Sep 2026 00:35:10 +0800 Subject: [PATCH 1/3] Expose cumulative display reasoning on session responses --- .../LanguageModelSession.swift | 53 ++++-- .../Shared/LocalGenerationUsage.swift | 3 +- .../ReasoningTests.swift | 168 ++++++++++++++++++ 3 files changed, 213 insertions(+), 11 deletions(-) create mode 100644 Tests/AnyLanguageModelTests/ReasoningTests.swift diff --git a/Sources/AnyLanguageModel/LanguageModelSession.swift b/Sources/AnyLanguageModel/LanguageModelSession.swift index 0a15533a..e7bf7d16 100644 --- a/Sources/AnyLanguageModel/LanguageModelSession.swift +++ b/Sources/AnyLanguageModel/LanguageModelSession.swift @@ -170,6 +170,10 @@ public final class LanguageModelSession: @unchecked Sendable { continuation.yield(snapshot) } + // AsyncThrowingStream may end normally when its task is cancelled. + // A partial snapshot must not become a completed transcript response. + try Task.checkCancellation() + // Commit the response to the transcript // before the stream reports completion, // so a caller that drains the stream @@ -343,6 +347,11 @@ public final class LanguageModelSession: @unchecked Sendable { /// with zero counts for values the provider does not report. public let usage: Usage + /// Provider-exposed reasoning text accumulated across this generation, including tool rounds. + /// This optional AnyLanguageModel extension is for display only, may be a summary, + /// and is not added to the transcript or used to replay provider state. + public let reasoning: String? + internal let providerMetadata: [String: String]? /// Creates a response value from generated content and transcript entries. @@ -350,6 +359,7 @@ public final class LanguageModelSession: @unchecked Sendable { /// - content: The decoded response content. /// - rawContent: The raw content produced by the model. /// - transcriptEntries: Transcript entries associated with the response. + /// - reasoning: Optional cumulative provider-exposed reasoning for display. /// - usage: Provider-reported token usage. public init( content: Content, @@ -358,14 +368,16 @@ public final class LanguageModelSession: @unchecked Sendable { usage: Usage = .init( input: .init(totalTokenCount: 0, cachedTokenCount: 0), output: .init(totalTokenCount: 0, reasoningTokenCount: 0) - ) + ), + reasoning: String? = nil ) { self.init( content: content, rawContent: rawContent, transcriptEntries: transcriptEntries, usage: usage, - providerMetadata: nil + providerMetadata: nil, + reasoning: reasoning ) } @@ -377,12 +389,14 @@ public final class LanguageModelSession: @unchecked Sendable { input: .init(totalTokenCount: 0, cachedTokenCount: 0), output: .init(totalTokenCount: 0, reasoningTokenCount: 0) ), - providerMetadata: [String: String]? + providerMetadata: [String: String]?, + reasoning: String? = nil ) { self.content = content self.rawContent = rawContent self.transcriptEntries = transcriptEntries self.usage = usage + self.reasoning = reasoning self.providerMetadata = providerMetadata } } @@ -1048,6 +1062,7 @@ extension LanguageModelSession { /// - Parameters: /// - content: The complete response content. /// - rawContent: The raw content produced by the model. + /// - reasoning: Optional cumulative provider-exposed reasoning for display. /// - usage: Provider-reported token usage. public init( content: Content, @@ -1055,12 +1070,14 @@ extension LanguageModelSession { usage: Usage = .init( input: .init(totalTokenCount: 0, cachedTokenCount: 0), output: .init(totalTokenCount: 0, reasoningTokenCount: 0) - ) + ), + reasoning: String? = nil ) { self.fallbackSnapshot = Snapshot( content: content.asPartiallyGenerated(), rawContent: rawContent, - usage: usage + usage: usage, + reasoning: reasoning ) self.streaming = nil } @@ -1074,6 +1091,10 @@ extension LanguageModelSession { } /// A snapshot of partially generated content and response metadata. + /// + /// A snapshot still requires a valid `Content.PartiallyGenerated` value. + /// For scalar or enum types that cannot represent an absent answer, a provider + /// may defer reasoning updates until a valid partial answer exists. public struct Snapshot: Sendable where Content.PartiallyGenerated: Sendable { /// The partially generated response content. public var content: Content.PartiallyGenerated @@ -1090,6 +1111,11 @@ extension LanguageModelSession { /// with zero counts for values the provider does not report. public var usage: Usage + /// Provider-exposed reasoning text accumulated so far across this generation, + /// including tool rounds. This display-only AnyLanguageModel extension may be + /// a summary; it is not transcript content or provider replay state. + public var reasoning: String? + internal var providerMetadata: [String: String]? /// Creates a snapshot from partially generated content and raw content. @@ -1097,6 +1123,7 @@ extension LanguageModelSession { /// - content: The partially generated content. /// - rawContent: The raw content produced by the model. /// - transcriptEntries: Transcript entries accumulated so far (tool calls/outputs). + /// - reasoning: Optional cumulative provider-exposed reasoning for display. /// - usage: Provider-reported token usage so far. public init( content: Content.PartiallyGenerated, @@ -1105,14 +1132,16 @@ extension LanguageModelSession { usage: Usage = .init( input: .init(totalTokenCount: 0, cachedTokenCount: 0), output: .init(totalTokenCount: 0, reasoningTokenCount: 0) - ) + ), + reasoning: String? = nil ) { self.init( content: content, rawContent: rawContent, transcriptEntries: transcriptEntries, usage: usage, - providerMetadata: nil + providerMetadata: nil, + reasoning: reasoning ) } @@ -1124,12 +1153,14 @@ extension LanguageModelSession { input: .init(totalTokenCount: 0, cachedTokenCount: 0), output: .init(totalTokenCount: 0, reasoningTokenCount: 0) ), - providerMetadata: [String: String]? + providerMetadata: [String: String]?, + reasoning: String? = nil ) { self.content = content self.rawContent = rawContent self.transcriptEntries = transcriptEntries self.usage = usage + self.reasoning = reasoning self.providerMetadata = providerMetadata } } @@ -1195,7 +1226,8 @@ extension LanguageModelSession.ResponseStream: AsyncSequence { rawContent: last.rawContent, transcriptEntries: last.transcriptEntries, usage: last.usage, - providerMetadata: last.providerMetadata + providerMetadata: last.providerMetadata, + reasoning: last.reasoning ) } } @@ -1212,7 +1244,8 @@ extension LanguageModelSession.ResponseStream: AsyncSequence { rawContent: fallbackSnapshot.rawContent, transcriptEntries: fallbackSnapshot.transcriptEntries, usage: fallbackSnapshot.usage, - providerMetadata: fallbackSnapshot.providerMetadata + providerMetadata: fallbackSnapshot.providerMetadata, + reasoning: fallbackSnapshot.reasoning ) } diff --git a/Sources/AnyLanguageModel/Shared/LocalGenerationUsage.swift b/Sources/AnyLanguageModel/Shared/LocalGenerationUsage.swift index 42a50dfb..4681f2b7 100644 --- a/Sources/AnyLanguageModel/Shared/LocalGenerationUsage.swift +++ b/Sources/AnyLanguageModel/Shared/LocalGenerationUsage.swift @@ -86,7 +86,8 @@ extension LanguageModel { content: response.content.asPartiallyGenerated(), rawContent: response.rawContent, transcriptEntries: response.transcriptEntries, - usage: response.usage + usage: response.usage, + reasoning: response.reasoning ) ) continuation.finish() diff --git a/Tests/AnyLanguageModelTests/ReasoningTests.swift b/Tests/AnyLanguageModelTests/ReasoningTests.swift new file mode 100644 index 00000000..e06457f8 --- /dev/null +++ b/Tests/AnyLanguageModelTests/ReasoningTests.swift @@ -0,0 +1,168 @@ +import Foundation +import Testing + +@testable import AnyLanguageModel + +@Suite("Display reasoning") +struct ReasoningTests { + @Test func existingInitializersDefaultToNil() async throws { + let raw = GeneratedContent("Answer") + let response = LanguageModelSession.Response(content: "Answer", rawContent: raw, transcriptEntries: []) + let snapshot = LanguageModelSession.ResponseStream.Snapshot(content: "Answer", rawContent: raw) + let stream = LanguageModelSession.ResponseStream(content: "Answer", rawContent: raw) + #expect(response.reasoning == nil) + #expect(snapshot.reasoning == nil) + #expect(try await stream.collect().reasoning == nil) + } + + @Test func singleValueAndStructuredWrapperPreserveReasoning() async throws { + let stream = LanguageModelSession.ResponseStream( + content: "Answer", + rawContent: GeneratedContent("Answer"), + reasoning: "Display summary" + ) + #expect(try await stream.collect().reasoning == "Display summary") + let wrapped: LanguageModelSession.ResponseStream = ReasoningModel().streamStructuredResponse { + .init( + content: "Answer", + rawContent: GeneratedContent("Answer"), + transcriptEntries: [], + reasoning: "Display summary" + ) + } + #expect(try await wrapped.collect().reasoning == "Display summary") + } + + @Test func sessionYieldsReasoningOnlyChangesAndCollectPreservesFinalValue() async throws { + let session = LanguageModelSession(model: ReasoningModel()) + var reasoning: [String?] = [] + var content: [String] = [] + for try await snapshot in session.streamResponse(to: "Question") { + reasoning.append(snapshot.reasoning) + content.append(snapshot.content) + } + #expect(reasoning == ["First", "First second", "First second"]) + #expect(content == ["", "", "Answer"]) + try assertAnswerOnly(in: session) + let response = try await session.streamResponse(to: "Again").collect() + #expect(response.content == "Answer") + #expect(response.reasoning == "First second") + try assertAnswerOnly(in: session) + } + + @Test func respondPreservesReasoningWithoutPersistingIt() async throws { + let session = LanguageModelSession(model: ReasoningModel()) + let response = try await session.respond(to: "Question") + #expect(response.content == "Answer") + #expect(response.reasoning == "First second") + try assertAnswerOnly(in: session) + } + + @Test func schemaOverloadsPreserveReasoning() async throws { + let session = LanguageModelSession(model: ReasoningModel()) + let response = try await session.respond(to: "Question", schema: String.generationSchema) + #expect(response.reasoning == "First second") + #expect(response.rawContent == GeneratedContent("Answer")) + let streamed = try await session.streamResponse(to: "Again", schema: String.generationSchema).collect() + #expect(streamed.reasoning == "First second") + #expect(streamed.rawContent == GeneratedContent("Answer")) + try assertAnswerOnly(in: session) + } + + @Test func cancelledReasoningStreamDoesNotCommitAssistantResponse() async throws { + let (stopped, stopSignal) = AsyncStream.makeStream() + let session = LanguageModelSession(model: ReasoningModel(stopSignal: stopSignal)) + let consumer = Task { + for try await snapshot in session.streamResponse(to: "Question") { + #expect(snapshot.content == "") + #expect(snapshot.reasoning == "First") + withUnsafeCurrentTask { $0?.cancel() } + } + } + _ = await consumer.result + for await _ in stopped {} + // The provider termination callback runs before the relay finishes cleanup. + while session.isResponding { await Task.yield() } + #expect(session.transcript.count == 1) + #expect(!session.isResponding) + } + + private func assertAnswerOnly(in session: LanguageModelSession) throws { + let responses = session.transcript.compactMap { entry -> Transcript.Response? in + if case .response(let response) = entry { return response } + return nil + } + #expect(!responses.isEmpty) + for response in responses { + #expect(response.segments.count == 1) + guard case .text(let text) = response.segments.first else { + Issue.record("Expected only an answer text segment") + return + } + #expect(text.content == "Answer") + } + let encoded = try JSONEncoder().encode(session.transcript) + #expect(!String(decoding: encoded, as: UTF8.self).contains("First")) + } +} + +private struct ReasoningModel: LanguageModel { + typealias UnavailableReason = Never + var stopSignal: AsyncStream.Continuation? = nil + + 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 { + if let stopSignal { + continuation.onTermination = { _ in stopSignal.finish() } + let raw = GeneratedContent("") + continuation.yield( + .init( + content: try Content(raw).asPartiallyGenerated(), + rawContent: raw, + reasoning: "First" + ) + ) + return + } + for (text, reasoning) in [("", "First"), ("", "First second"), ("Answer", "First second")] { + let raw = GeneratedContent(text) + continuation.yield( + .init( + content: try Content(raw).asPartiallyGenerated(), + rawContent: raw, + reasoning: reasoning + ) + ) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + ) + } +} From 61879c3611846feca1f6daa560ee8c03d7d8f08b Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Fri, 25 Sep 2026 04:34:39 +0800 Subject: [PATCH 2/3] Align reasoning with FM27 transcripts and separate cancellation changes --- README.md | 40 +++ .../LanguageModelSession.swift | 57 +--- .../Models/AnthropicLanguageModel.swift | 117 ++++++-- .../Models/FoundationLanguageModel.swift | 36 +-- .../Models/GeminiLanguageModel.swift | 8 +- .../Models/LlamaLanguageModel.swift | 2 + .../Models/MLXLanguageModel.swift | 10 +- .../Models/OllamaLanguageModel.swift | 2 + .../Models/OpenAILanguageModel.swift | 10 +- .../Models/OpenResponsesLanguageModel.swift | 8 +- .../Models/SystemLanguageModel.swift | 56 ++-- .../Shared/LocalGenerationUsage.swift | 3 +- Sources/AnyLanguageModel/Transcript.swift | 33 +++ .../AnthropicReasoningTests.swift | 262 ++++++++++++++++++ .../ReasoningTests.swift | 150 ++++------ 15 files changed, 575 insertions(+), 219 deletions(-) create mode 100644 Tests/AnyLanguageModelTests/AnthropicReasoningTests.swift diff --git a/README.md b/README.md index 0f5c5a8e..46c1bd62 100644 --- a/README.md +++ b/README.md @@ -439,6 +439,46 @@ 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. +Other adapters currently reject reasoning replay explicitly rather than flattening +it into answer text or silently dropping it. 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 e7bf7d16..ad8b5062 100644 --- a/Sources/AnyLanguageModel/LanguageModelSession.swift +++ b/Sources/AnyLanguageModel/LanguageModelSession.swift @@ -170,10 +170,6 @@ public final class LanguageModelSession: @unchecked Sendable { continuation.yield(snapshot) } - // AsyncThrowingStream may end normally when its task is cancelled. - // A partial snapshot must not become a completed transcript response. - try Task.checkCancellation() - // Commit the response to the transcript // before the stream reports completion, // so a caller that drains the stream @@ -347,11 +343,6 @@ public final class LanguageModelSession: @unchecked Sendable { /// with zero counts for values the provider does not report. public let usage: Usage - /// Provider-exposed reasoning text accumulated across this generation, including tool rounds. - /// This optional AnyLanguageModel extension is for display only, may be a summary, - /// and is not added to the transcript or used to replay provider state. - public let reasoning: String? - internal let providerMetadata: [String: String]? /// Creates a response value from generated content and transcript entries. @@ -359,7 +350,6 @@ public final class LanguageModelSession: @unchecked Sendable { /// - content: The decoded response content. /// - rawContent: The raw content produced by the model. /// - transcriptEntries: Transcript entries associated with the response. - /// - reasoning: Optional cumulative provider-exposed reasoning for display. /// - usage: Provider-reported token usage. public init( content: Content, @@ -368,16 +358,14 @@ public final class LanguageModelSession: @unchecked Sendable { usage: Usage = .init( input: .init(totalTokenCount: 0, cachedTokenCount: 0), output: .init(totalTokenCount: 0, reasoningTokenCount: 0) - ), - reasoning: String? = nil + ) ) { self.init( content: content, rawContent: rawContent, transcriptEntries: transcriptEntries, usage: usage, - providerMetadata: nil, - reasoning: reasoning + providerMetadata: nil ) } @@ -389,14 +377,12 @@ public final class LanguageModelSession: @unchecked Sendable { input: .init(totalTokenCount: 0, cachedTokenCount: 0), output: .init(totalTokenCount: 0, reasoningTokenCount: 0) ), - providerMetadata: [String: String]?, - reasoning: String? = nil + providerMetadata: [String: String]? ) { self.content = content self.rawContent = rawContent self.transcriptEntries = transcriptEntries self.usage = usage - self.reasoning = reasoning self.providerMetadata = providerMetadata } } @@ -1062,7 +1048,6 @@ extension LanguageModelSession { /// - Parameters: /// - content: The complete response content. /// - rawContent: The raw content produced by the model. - /// - reasoning: Optional cumulative provider-exposed reasoning for display. /// - usage: Provider-reported token usage. public init( content: Content, @@ -1070,14 +1055,12 @@ extension LanguageModelSession { usage: Usage = .init( input: .init(totalTokenCount: 0, cachedTokenCount: 0), output: .init(totalTokenCount: 0, reasoningTokenCount: 0) - ), - reasoning: String? = nil + ) ) { self.fallbackSnapshot = Snapshot( content: content.asPartiallyGenerated(), rawContent: rawContent, - usage: usage, - reasoning: reasoning + usage: usage ) self.streaming = nil } @@ -1091,10 +1074,6 @@ extension LanguageModelSession { } /// A snapshot of partially generated content and response metadata. - /// - /// A snapshot still requires a valid `Content.PartiallyGenerated` value. - /// For scalar or enum types that cannot represent an absent answer, a provider - /// may defer reasoning updates until a valid partial answer exists. public struct Snapshot: Sendable where Content.PartiallyGenerated: Sendable { /// The partially generated response content. public var content: Content.PartiallyGenerated @@ -1102,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 @@ -1111,19 +1090,13 @@ extension LanguageModelSession { /// with zero counts for values the provider does not report. public var usage: Usage - /// Provider-exposed reasoning text accumulated so far across this generation, - /// including tool rounds. This display-only AnyLanguageModel extension may be - /// a summary; it is not transcript content or provider replay state. - public var reasoning: String? - internal var providerMetadata: [String: String]? /// Creates a snapshot from partially generated content and raw content. /// - Parameters: /// - content: The partially generated content. /// - rawContent: The raw content produced by the model. - /// - transcriptEntries: Transcript entries accumulated so far (tool calls/outputs). - /// - reasoning: Optional cumulative provider-exposed reasoning for display. + /// - transcriptEntries: Transcript entries accumulated so far (reasoning/tool calls/outputs). /// - usage: Provider-reported token usage so far. public init( content: Content.PartiallyGenerated, @@ -1132,16 +1105,14 @@ extension LanguageModelSession { usage: Usage = .init( input: .init(totalTokenCount: 0, cachedTokenCount: 0), output: .init(totalTokenCount: 0, reasoningTokenCount: 0) - ), - reasoning: String? = nil + ) ) { self.init( content: content, rawContent: rawContent, transcriptEntries: transcriptEntries, usage: usage, - providerMetadata: nil, - reasoning: reasoning + providerMetadata: nil ) } @@ -1153,14 +1124,12 @@ extension LanguageModelSession { input: .init(totalTokenCount: 0, cachedTokenCount: 0), output: .init(totalTokenCount: 0, reasoningTokenCount: 0) ), - providerMetadata: [String: String]?, - reasoning: String? = nil + providerMetadata: [String: String]? ) { self.content = content self.rawContent = rawContent self.transcriptEntries = transcriptEntries self.usage = usage - self.reasoning = reasoning self.providerMetadata = providerMetadata } } @@ -1226,8 +1195,7 @@ extension LanguageModelSession.ResponseStream: AsyncSequence { rawContent: last.rawContent, transcriptEntries: last.transcriptEntries, usage: last.usage, - providerMetadata: last.providerMetadata, - reasoning: last.reasoning + providerMetadata: last.providerMetadata ) } } @@ -1244,8 +1212,7 @@ extension LanguageModelSession.ResponseStream: AsyncSequence { rawContent: fallbackSnapshot.rawContent, transcriptEntries: fallbackSnapshot.transcriptEntries, usage: fallbackSnapshot.usage, - providerMetadata: fallbackSnapshot.providerMetadata, - reasoning: fallbackSnapshot.reasoning + providerMetadata: fallbackSnapshot.providerMetadata ) } diff --git a/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift b/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift index 9371ba7b..16f9a8ee 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,28 @@ extension Transcript { content: convertSegmentsToAnthropicContent(prompt.segments) ) ) + case .reasoning(let reasoning): + guard reasoning.metadata["provider"] == GeneratedContent("anthropic") else { + throw Transcript.ReasoningReplayError.unsupportedProvider("AnthropicLanguageModel") + } + 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 +1061,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 +1108,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 +1131,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 +1143,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 +1213,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 +1311,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 +1336,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 +1429,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/FoundationLanguageModel.swift b/Sources/AnyLanguageModel/Models/FoundationLanguageModel.swift index 307c992d..562734d0 100644 --- a/Sources/AnyLanguageModel/Models/FoundationLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/FoundationLanguageModel.swift @@ -158,7 +158,7 @@ options: GenerationOptions ) async throws -> LanguageModelSession.Response where Content: Generable { let fmTools = session.tools.toFoundationModels() - let fmTranscript = fmTranscriptDroppingDuplicatePrompt(session.transcript, prompt: prompt) + let fmTranscript = try fmTranscriptDroppingDuplicatePrompt(session.transcript, prompt: prompt) .toFoundationModels( instructions: session.instructions, toolDefinitions: session.tools @@ -217,22 +217,26 @@ includeSchemaInPrompt: Bool, options: GenerationOptions ) -> sending LanguageModelSession.ResponseStream where Content: Generable { - let fmTools = session.tools.toFoundationModels() - let fmTranscript = fmTranscriptDroppingDuplicatePrompt(session.transcript, prompt: prompt) - .toFoundationModels( - instructions: session.instructions, - toolDefinitions: session.tools - .filter(\.includesSchemaInInstructions) - .map { Transcript.ToolDefinition(tool: $0) } + do { + let fmTools = session.tools.toFoundationModels() + let fmTranscript = try fmTranscriptDroppingDuplicatePrompt(session.transcript, prompt: prompt) + .toFoundationModels( + instructions: session.instructions, + toolDefinitions: session.tools + .filter(\.includesSchemaInInstructions) + .map { Transcript.ToolDefinition(tool: $0) } + ) + return fmStreamResponse( + makeSession: { try await self.makeSession(tools: fmTools, transcript: fmTranscript) }, + fmPrompt: prompt.toFoundationModels(), + fmOptions: options.toFoundationModels(), + type: type, + schema: schema, + includeSchemaInPrompt: includeSchemaInPrompt ) - return fmStreamResponse( - makeSession: { try await self.makeSession(tools: fmTools, transcript: fmTranscript) }, - fmPrompt: prompt.toFoundationModels(), - fmOptions: options.toFoundationModels(), - type: type, - schema: schema, - includeSchemaInPrompt: includeSchemaInPrompt - ) + } catch { + return .init(stream: AsyncThrowingStream { $0.finish(throwing: error) }) + } } } #endif diff --git a/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift b/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift index ba06a0c8..d35ec9d8 100644 --- a/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift @@ -328,7 +328,7 @@ public struct GeminiLanguageModel: LanguageModel { // Multi-turn conversation loop for tool calling while true { let params = try createGenerateContentParams( - contents: transcript.toGeminiContent(), + contents: try transcript.toGeminiContent(), tools: geminiTools, generating: type, schema: schema, @@ -494,7 +494,7 @@ public struct GeminiLanguageModel: LanguageModel { while true { try Task.checkCancellation() let params = try createGenerateContentParams( - contents: transcript.toGeminiContent(), + contents: try transcript.toGeminiContent(), tools: geminiTools, generating: type, schema: schema, @@ -854,7 +854,7 @@ private func toJSONValue(_ toolOutput: Transcript.ToolOutput) throws -> [String: // MARK: - Supporting Types extension Transcript { - fileprivate func toGeminiContent() -> [GeminiContent] { + fileprivate func toGeminiContent() throws -> [GeminiContent] { var messages = [GeminiContent]() for item in self { switch item { @@ -872,6 +872,8 @@ extension Transcript { parts: convertSegmentsToGeminiParts(prompt.segments) ) ) + case .reasoning: + throw Transcript.ReasoningReplayError.unsupportedProvider("GeminiLanguageModel") case .response(let response): messages.append( .init( diff --git a/Sources/AnyLanguageModel/Models/LlamaLanguageModel.swift b/Sources/AnyLanguageModel/Models/LlamaLanguageModel.swift index 68c5ea1d..8b9dfa50 100644 --- a/Sources/AnyLanguageModel/Models/LlamaLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/LlamaLanguageModel.swift @@ -2320,6 +2320,8 @@ import Foundation messages.append(("user", text)) } + case .reasoning: + throw Transcript.ReasoningReplayError.unsupportedProvider("LlamaLanguageModel") 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..dcbbfdb6 100644 --- a/Sources/AnyLanguageModel/Models/MLXLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/MLXLanguageModel.swift @@ -1022,7 +1022,7 @@ import Foundation ?? .init(resize: nil) // Build chat history from full transcript - var chat = convertTranscriptToMLXChat(session: session, fallbackPrompt: prompt.description) + var chat = try convertTranscriptToMLXChat(session: session, fallbackPrompt: prompt.description) var usage = LanguageModelSession.Usage.zero var allTextChunks: [String] = [] @@ -1260,7 +1260,7 @@ import Foundation options[custom: MLXLanguageModel.self]?.processingForUserInput ?? .init(resize: nil) let toolSpecs = mlxToolSpecs(for: session) - var chat = convertTranscriptToMLXChat( + var chat = try convertTranscriptToMLXChat( session: session, fallbackPrompt: prompt.description ) @@ -1535,7 +1535,7 @@ import Foundation private func convertTranscriptToMLXChat( session: LanguageModelSession, fallbackPrompt: String - ) -> [MLXLMCommon.Chat.Message] { + ) throws -> [MLXLMCommon.Chat.Message] { var chat: [MLXLMCommon.Chat.Message] = [] // Check if instructions are already in transcript @@ -1561,6 +1561,8 @@ import Foundation case .prompt(let prompt): chat.append(makeMLXChatMessage(from: prompt.segments, role: .user)) + case .reasoning: + throw Transcript.ReasoningReplayError.unsupportedProvider("MLXLanguageModel") case .response(let response): let content = response.segments.map { extractText(from: $0) }.joined(separator: "\n") chat.append(.assistant(content)) @@ -1918,7 +1920,7 @@ import Foundation let maxTokens = options.maximumResponseTokens ?? 512 let generateParameters = toStructuredGenerateParameters(options) - let baseChat = convertTranscriptToMLXChat(session: session, fallbackPrompt: prompt.description) + let baseChat = try convertTranscriptToMLXChat(session: session, fallbackPrompt: prompt.description) let schemaPrompt = includeSchemaInPrompt ? schemaPrompt(for: schema) : nil let chat = normalizeChatForStructuredGeneration(baseChat, schemaPrompt: schemaPrompt) diff --git a/Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift b/Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift index fcbb7f11..0f1daaae 100644 --- a/Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift @@ -612,6 +612,8 @@ private extension Transcript { case .prompt(let prompt): role = .user segments = prompt.segments + case .reasoning: + throw Transcript.ReasoningReplayError.unsupportedProvider("OllamaLanguageModel") case .response(let response): role = .assistant segments = response.segments diff --git a/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift b/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift index a0556fd5..10f288c9 100644 --- a/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift @@ -479,7 +479,7 @@ public struct OpenAILanguageModel: LanguageModel { switch apiVariant { case .chatCompletions: return try await respondWithChatCompletions( - messages: session.transcript.toOpenAIMessages(), + messages: try session.transcript.toOpenAIMessages(), tools: openAITools, generating: type, schema: schema, @@ -488,7 +488,7 @@ public struct OpenAILanguageModel: LanguageModel { ) case .responses: return try await respondWithResponses( - messages: session.transcript.toOpenAIMessages(), + messages: try session.transcript.toOpenAIMessages(), tools: openAITools, generating: type, schema: schema, @@ -779,7 +779,7 @@ public struct OpenAILanguageModel: LanguageModel { continuation in let task = Task { do { - var messages = session.transcript.toOpenAIMessages() + var messages = try session.transcript.toOpenAIMessages() var state = StreamingResponseState() var toolRounds = ToolRoundLimit(provider: "OpenAI") while true { @@ -1309,7 +1309,7 @@ private enum Responses { // MARK: - Supporting Types extension Transcript { - fileprivate func toOpenAIMessages() -> [OpenAIMessage] { + fileprivate func toOpenAIMessages() throws -> [OpenAIMessage] { var messages = [OpenAIMessage]() for item in self { switch item { @@ -1327,6 +1327,8 @@ extension Transcript { content: .blocks(convertSegmentsToOpenAIBlocks(prompt.segments)) ) ) + case .reasoning: + throw Transcript.ReasoningReplayError.unsupportedProvider("OpenAILanguageModel") case .response(let response): messages.append( .init( diff --git a/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift b/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift index ad7cdacb..d1b6478f 100644 --- a/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift @@ -435,7 +435,7 @@ public struct OpenResponsesLanguageModel: LanguageModel { let tools: [OpenResponsesTool]? = session.tools.isEmpty ? nil : session.tools.map { convertToolToOpenResponsesFormat($0) } return try await respondWithOpenResponses( - messages: session.transcript.toOpenResponsesMessages(), + messages: try session.transcript.toOpenResponsesMessages(), tools: tools, generating: type, schema: schema, @@ -493,7 +493,7 @@ public struct OpenResponsesLanguageModel: LanguageModel { continuation in let task = Task { do { - var messages = session.transcript.toOpenResponsesMessages() + var messages = try session.transcript.toOpenResponsesMessages() var state = StreamingResponseState() var toolRounds = ToolRoundLimit(provider: "Open Responses") while true { @@ -901,7 +901,7 @@ private enum OpenResponsesBlock: Sendable { } extension Transcript { - fileprivate func toOpenResponsesMessages() -> [OpenResponsesMessage] { + fileprivate func toOpenResponsesMessages() throws -> [OpenResponsesMessage] { var list: [OpenResponsesMessage] = [] for item in self { switch item { @@ -919,6 +919,8 @@ extension Transcript { content: .blocks(openResponsesConvertSegmentsToBlocks(prompt.segments)) ) ) + case .reasoning: + throw Transcript.ReasoningReplayError.unsupportedProvider("OpenResponsesLanguageModel") case .response(let response): list.append( OpenResponsesMessage( diff --git a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift index d4f10119..60399d45 100644 --- a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift @@ -131,12 +131,13 @@ let fmSession = FoundationModels.LanguageModelSession( model: systemModel, tools: session.tools.toFoundationModels(), - transcript: fmTranscriptDroppingDuplicatePrompt(session.transcript, prompt: prompt).toFoundationModels( - instructions: session.instructions, - toolDefinitions: session.tools - .filter(\.includesSchemaInInstructions) - .map { Transcript.ToolDefinition(tool: $0) } - ) + transcript: try fmTranscriptDroppingDuplicatePrompt(session.transcript, prompt: prompt) + .toFoundationModels( + instructions: session.instructions, + toolDefinitions: session.tools + .filter(\.includesSchemaInInstructions) + .map { Transcript.ToolDefinition(tool: $0) } + ) ) return try await fmRespond( @@ -194,25 +195,30 @@ let fmPrompt = prompt.toFoundationModels() let fmOptions = options.toFoundationModels() - let fmSession = FoundationModels.LanguageModelSession( - model: systemModel, - tools: session.tools.toFoundationModels(), - transcript: fmTranscriptDroppingDuplicatePrompt(session.transcript, prompt: prompt).toFoundationModels( - instructions: session.instructions, - toolDefinitions: session.tools - .filter(\.includesSchemaInInstructions) - .map { Transcript.ToolDefinition(tool: $0) } + do { + let fmSession = FoundationModels.LanguageModelSession( + model: systemModel, + tools: session.tools.toFoundationModels(), + transcript: try fmTranscriptDroppingDuplicatePrompt(session.transcript, prompt: prompt) + .toFoundationModels( + instructions: session.instructions, + toolDefinitions: session.tools + .filter(\.includesSchemaInInstructions) + .map { Transcript.ToolDefinition(tool: $0) } + ) ) - ) - return fmStreamResponse( - makeSession: { fmSession }, - fmPrompt: fmPrompt, - fmOptions: fmOptions, - type: type, - schema: schema, - includeSchemaInPrompt: includeSchemaInPrompt - ) + return fmStreamResponse( + makeSession: { fmSession }, + fmPrompt: fmPrompt, + fmOptions: fmOptions, + type: type, + schema: schema, + includeSchemaInPrompt: includeSchemaInPrompt + ) + } catch { + return .init(stream: AsyncThrowingStream { $0.finish(throwing: error) }) + } } public func logFeedbackAttachment( @@ -568,7 +574,7 @@ func toFoundationModels( instructions: AnyLanguageModel.Instructions?, toolDefinitions: [Transcript.ToolDefinition] - ) -> FoundationModels.Transcript { + ) throws -> FoundationModels.Transcript { var fmEntries: [FoundationModels.Transcript.Entry] = [] // Add instructions entry if provided and not already in transcript @@ -606,6 +612,8 @@ ) fmEntries.append(.prompt(fmPrompt)) + case .reasoning: + throw Transcript.ReasoningReplayError.unsupportedProvider("SystemLanguageModel") case .response(let response): let fmSegments = response.segments.toFoundationModels() let fmResponse = FoundationModels.Transcript.Response( diff --git a/Sources/AnyLanguageModel/Shared/LocalGenerationUsage.swift b/Sources/AnyLanguageModel/Shared/LocalGenerationUsage.swift index 4681f2b7..42a50dfb 100644 --- a/Sources/AnyLanguageModel/Shared/LocalGenerationUsage.swift +++ b/Sources/AnyLanguageModel/Shared/LocalGenerationUsage.swift @@ -86,8 +86,7 @@ extension LanguageModel { content: response.content.asPartiallyGenerated(), rawContent: response.rawContent, transcriptEntries: response.transcriptEntries, - usage: response.usage, - reasoning: response.reasoning + usage: response.usage ) ) continuation.finish() 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..8bf03de6 --- /dev/null +++ b/Tests/AnyLanguageModelTests/AnthropicReasoningTests.swift @@ -0,0 +1,262 @@ +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 func unsupportedReplayFailsBeforeNetwork() async throws { + ReasoningURLProtocol.reset() + let transcript = Transcript(entries: [ + .reasoning(.init(segments: [.text(.init(content: "Other provider"))])) + ]) + let session = LanguageModelSession(model: model(), transcript: transcript) + await #expect(throws: Transcript.ReasoningReplayError.self) { _ = try await session.respond(to: "Next") } + #expect(ReasoningURLProtocol.recordedBodies.isEmpty) + } + + 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 index e06457f8..66d78afb 100644 --- a/Tests/AnyLanguageModelTests/ReasoningTests.swift +++ b/Tests/AnyLanguageModelTests/ReasoningTests.swift @@ -3,112 +3,68 @@ import Testing @testable import AnyLanguageModel -@Suite("Display reasoning") +@Suite("Reasoning transcript") struct ReasoningTests { - @Test func existingInitializersDefaultToNil() async throws { - let raw = GeneratedContent("Answer") - let response = LanguageModelSession.Response(content: "Answer", rawContent: raw, transcriptEntries: []) - let snapshot = LanguageModelSession.ResponseStream.Snapshot(content: "Answer", rawContent: raw) - let stream = LanguageModelSession.ResponseStream(content: "Answer", rawContent: raw) - #expect(response.reasoning == nil) - #expect(snapshot.reasoning == nil) - #expect(try await stream.collect().reasoning == nil) + @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 singleValueAndStructuredWrapperPreserveReasoning() async throws { - let stream = LanguageModelSession.ResponseStream( - content: "Answer", - rawContent: GeneratedContent("Answer"), - reasoning: "Display summary" + @available(macOS 26.0, iOS 26.0, *) + @Test func originalInitializerFunctionReferencesCompile() async throws { + let responseInit = LanguageModelSession.Response.init(content:rawContent:transcriptEntries:usage:) + let snapshotInit = LanguageModelSession.ResponseStream.Snapshot.init( + content: + rawContent: + transcriptEntries: + usage: ) - #expect(try await stream.collect().reasoning == "Display summary") - let wrapped: LanguageModelSession.ResponseStream = ReasoningModel().streamStructuredResponse { - .init( - content: "Answer", - rawContent: GeneratedContent("Answer"), - transcriptEntries: [], - reasoning: "Display summary" - ) - } - #expect(try await wrapped.collect().reasoning == "Display summary") + 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 sessionYieldsReasoningOnlyChangesAndCollectPreservesFinalValue() async throws { + @Test func cumulativeEntriesPersistOnceAndDoNotPolluteAnswer() async throws { let session = LanguageModelSession(model: ReasoningModel()) - var reasoning: [String?] = [] - var content: [String] = [] + var ids: [String] = [] for try await snapshot in session.streamResponse(to: "Question") { - reasoning.append(snapshot.reasoning) - content.append(snapshot.content) + ids.append(try #require(snapshot.transcriptEntries.first?.id)) + let visible: String = snapshot.content + #expect(!visible.contains("First")) } - #expect(reasoning == ["First", "First second", "First second"]) - #expect(content == ["", "", "Answer"]) - try assertAnswerOnly(in: session) - let response = try await session.streamResponse(to: "Again").collect() - #expect(response.content == "Answer") - #expect(response.reasoning == "First second") - try assertAnswerOnly(in: session) - } - - @Test func respondPreservesReasoningWithoutPersistingIt() async throws { - let session = LanguageModelSession(model: ReasoningModel()) - let response = try await session.respond(to: "Question") - #expect(response.content == "Answer") - #expect(response.reasoning == "First second") - try assertAnswerOnly(in: session) + #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 schemaOverloadsPreserveReasoning() async throws { + @Test func nonstreamAndSchemaRetainEntries() async throws { let session = LanguageModelSession(model: ReasoningModel()) let response = try await session.respond(to: "Question", schema: String.generationSchema) - #expect(response.reasoning == "First second") + #expect(response.transcriptEntries.count == 1) #expect(response.rawContent == GeneratedContent("Answer")) let streamed = try await session.streamResponse(to: "Again", schema: String.generationSchema).collect() - #expect(streamed.reasoning == "First second") + #expect(streamed.transcriptEntries.count == 1) #expect(streamed.rawContent == GeneratedContent("Answer")) - try assertAnswerOnly(in: session) - } - - @Test func cancelledReasoningStreamDoesNotCommitAssistantResponse() async throws { - let (stopped, stopSignal) = AsyncStream.makeStream() - let session = LanguageModelSession(model: ReasoningModel(stopSignal: stopSignal)) - let consumer = Task { - for try await snapshot in session.streamResponse(to: "Question") { - #expect(snapshot.content == "") - #expect(snapshot.reasoning == "First") - withUnsafeCurrentTask { $0?.cancel() } - } - } - _ = await consumer.result - for await _ in stopped {} - // The provider termination callback runs before the relay finishes cleanup. - while session.isResponding { await Task.yield() } - #expect(session.transcript.count == 1) - #expect(!session.isResponding) } - private func assertAnswerOnly(in session: LanguageModelSession) throws { - let responses = session.transcript.compactMap { entry -> Transcript.Response? in - if case .response(let response) = entry { return response } - return nil - } - #expect(!responses.isEmpty) - for response in responses { - #expect(response.segments.count == 1) - guard case .text(let text) = response.segments.first else { - Issue.record("Expected only an answer text segment") - return - } - #expect(text.content == "Answer") - } - let encoded = try JSONEncoder().encode(session.transcript) - #expect(!String(decoding: encoded, as: UTF8.self).contains("First")) - } } private struct ReasoningModel: LanguageModel { typealias UnavailableReason = Never - var stopSignal: AsyncStream.Continuation? = nil func respond( within session: LanguageModelSession, @@ -136,32 +92,26 @@ private struct ReasoningModel: LanguageModel { .init( stream: AsyncThrowingStream { continuation in do { - if let stopSignal { - continuation.onTermination = { _ in stopSignal.finish() } - let raw = GeneratedContent("") - continuation.yield( - .init( - content: try Content(raw).asPartiallyGenerated(), - rawContent: raw, - reasoning: "First" - ) - ) - return - } 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, - reasoning: reasoning + transcriptEntries: ArraySlice(entries) ) ) } continuation.finish() - } catch { - continuation.finish(throwing: error) - } + } catch { continuation.finish(throwing: error) } } ) } From 748539e1f33f921d5f922d0366fe31c812376f32 Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Sat, 26 Sep 2026 04:08:21 +0800 Subject: [PATCH 3/3] Allow cross-provider reasoning history and fix watchOS test availability --- README.md | 7 +- .../Models/AnthropicLanguageModel.swift | 3 +- .../Models/FoundationLanguageModel.swift | 36 ++--- .../Models/GeminiLanguageModel.swift | 9 +- .../Models/LlamaLanguageModel.swift | 3 +- .../Models/MLXLanguageModel.swift | 11 +- .../Models/OllamaLanguageModel.swift | 5 +- .../Models/OpenAILanguageModel.swift | 11 +- .../Models/OpenResponsesLanguageModel.swift | 9 +- .../Models/SystemLanguageModel.swift | 57 ++++--- .../AnthropicReasoningTests.swift | 143 +++++++++++++++++- .../ReasoningTests.swift | 2 +- 12 files changed, 217 insertions(+), 79 deletions(-) diff --git a/README.md b/README.md index 46c1bd62..e4194d12 100644 --- a/README.md +++ b/README.md @@ -474,8 +474,11 @@ 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. -Other adapters currently reject reasoning replay explicitly rather than flattening -it into answer text or silently dropping it. For structured scalar outputs that +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. diff --git a/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift b/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift index 16f9a8ee..9e8f4356 100644 --- a/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift @@ -1029,7 +1029,8 @@ extension Transcript { ) case .reasoning(let reasoning): guard reasoning.metadata["provider"] == GeneratedContent("anthropic") else { - throw Transcript.ReasoningReplayError.unsupportedProvider("AnthropicLanguageModel") + // Foreign reasoning remains display history, not Anthropic replay state. + continue } guard let data = reasoning.signature, let signature = String(data: data, encoding: .utf8), !signature.isEmpty diff --git a/Sources/AnyLanguageModel/Models/FoundationLanguageModel.swift b/Sources/AnyLanguageModel/Models/FoundationLanguageModel.swift index 562734d0..307c992d 100644 --- a/Sources/AnyLanguageModel/Models/FoundationLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/FoundationLanguageModel.swift @@ -158,7 +158,7 @@ options: GenerationOptions ) async throws -> LanguageModelSession.Response where Content: Generable { let fmTools = session.tools.toFoundationModels() - let fmTranscript = try fmTranscriptDroppingDuplicatePrompt(session.transcript, prompt: prompt) + let fmTranscript = fmTranscriptDroppingDuplicatePrompt(session.transcript, prompt: prompt) .toFoundationModels( instructions: session.instructions, toolDefinitions: session.tools @@ -217,26 +217,22 @@ includeSchemaInPrompt: Bool, options: GenerationOptions ) -> sending LanguageModelSession.ResponseStream where Content: Generable { - do { - let fmTools = session.tools.toFoundationModels() - let fmTranscript = try fmTranscriptDroppingDuplicatePrompt(session.transcript, prompt: prompt) - .toFoundationModels( - instructions: session.instructions, - toolDefinitions: session.tools - .filter(\.includesSchemaInInstructions) - .map { Transcript.ToolDefinition(tool: $0) } - ) - return fmStreamResponse( - makeSession: { try await self.makeSession(tools: fmTools, transcript: fmTranscript) }, - fmPrompt: prompt.toFoundationModels(), - fmOptions: options.toFoundationModels(), - type: type, - schema: schema, - includeSchemaInPrompt: includeSchemaInPrompt + let fmTools = session.tools.toFoundationModels() + let fmTranscript = fmTranscriptDroppingDuplicatePrompt(session.transcript, prompt: prompt) + .toFoundationModels( + instructions: session.instructions, + toolDefinitions: session.tools + .filter(\.includesSchemaInInstructions) + .map { Transcript.ToolDefinition(tool: $0) } ) - } catch { - return .init(stream: AsyncThrowingStream { $0.finish(throwing: error) }) - } + return fmStreamResponse( + makeSession: { try await self.makeSession(tools: fmTools, transcript: fmTranscript) }, + fmPrompt: prompt.toFoundationModels(), + fmOptions: options.toFoundationModels(), + type: type, + schema: schema, + includeSchemaInPrompt: includeSchemaInPrompt + ) } } #endif diff --git a/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift b/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift index d35ec9d8..94e97d26 100644 --- a/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift @@ -328,7 +328,7 @@ public struct GeminiLanguageModel: LanguageModel { // Multi-turn conversation loop for tool calling while true { let params = try createGenerateContentParams( - contents: try transcript.toGeminiContent(), + contents: transcript.toGeminiContent(), tools: geminiTools, generating: type, schema: schema, @@ -494,7 +494,7 @@ public struct GeminiLanguageModel: LanguageModel { while true { try Task.checkCancellation() let params = try createGenerateContentParams( - contents: try transcript.toGeminiContent(), + contents: transcript.toGeminiContent(), tools: geminiTools, generating: type, schema: schema, @@ -854,7 +854,7 @@ private func toJSONValue(_ toolOutput: Transcript.ToolOutput) throws -> [String: // MARK: - Supporting Types extension Transcript { - fileprivate func toGeminiContent() throws -> [GeminiContent] { + fileprivate func toGeminiContent() -> [GeminiContent] { var messages = [GeminiContent]() for item in self { switch item { @@ -873,7 +873,8 @@ extension Transcript { ) ) case .reasoning: - throw Transcript.ReasoningReplayError.unsupportedProvider("GeminiLanguageModel") + // 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 8b9dfa50..686215a9 100644 --- a/Sources/AnyLanguageModel/Models/LlamaLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/LlamaLanguageModel.swift @@ -2321,7 +2321,8 @@ import Foundation } case .reasoning: - throw Transcript.ReasoningReplayError.unsupportedProvider("LlamaLanguageModel") + // 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 dcbbfdb6..fd574ac9 100644 --- a/Sources/AnyLanguageModel/Models/MLXLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/MLXLanguageModel.swift @@ -1022,7 +1022,7 @@ import Foundation ?? .init(resize: nil) // Build chat history from full transcript - var chat = try convertTranscriptToMLXChat(session: session, fallbackPrompt: prompt.description) + var chat = convertTranscriptToMLXChat(session: session, fallbackPrompt: prompt.description) var usage = LanguageModelSession.Usage.zero var allTextChunks: [String] = [] @@ -1260,7 +1260,7 @@ import Foundation options[custom: MLXLanguageModel.self]?.processingForUserInput ?? .init(resize: nil) let toolSpecs = mlxToolSpecs(for: session) - var chat = try convertTranscriptToMLXChat( + var chat = convertTranscriptToMLXChat( session: session, fallbackPrompt: prompt.description ) @@ -1535,7 +1535,7 @@ import Foundation private func convertTranscriptToMLXChat( session: LanguageModelSession, fallbackPrompt: String - ) throws -> [MLXLMCommon.Chat.Message] { + ) -> [MLXLMCommon.Chat.Message] { var chat: [MLXLMCommon.Chat.Message] = [] // Check if instructions are already in transcript @@ -1562,7 +1562,8 @@ import Foundation chat.append(makeMLXChatMessage(from: prompt.segments, role: .user)) case .reasoning: - throw Transcript.ReasoningReplayError.unsupportedProvider("MLXLanguageModel") + // 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)) @@ -1920,7 +1921,7 @@ import Foundation let maxTokens = options.maximumResponseTokens ?? 512 let generateParameters = toStructuredGenerateParameters(options) - let baseChat = try convertTranscriptToMLXChat(session: session, fallbackPrompt: prompt.description) + let baseChat = convertTranscriptToMLXChat(session: session, fallbackPrompt: prompt.description) let schemaPrompt = includeSchemaInPrompt ? schemaPrompt(for: schema) : nil let chat = normalizeChatForStructuredGeneration(baseChat, schemaPrompt: schemaPrompt) diff --git a/Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift b/Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift index 0f1daaae..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 { @@ -613,7 +613,8 @@ private extension Transcript { role = .user segments = prompt.segments case .reasoning: - throw Transcript.ReasoningReplayError.unsupportedProvider("OllamaLanguageModel") + // 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 ec56d30d..5498d644 100644 --- a/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift @@ -479,7 +479,7 @@ public struct OpenAILanguageModel: LanguageModel { switch apiVariant { case .chatCompletions: return try await respondWithChatCompletions( - messages: try session.transcript.toOpenAIMessages(), + messages: session.transcript.toOpenAIMessages(), tools: openAITools, generating: type, schema: schema, @@ -488,7 +488,7 @@ public struct OpenAILanguageModel: LanguageModel { ) case .responses: return try await respondWithResponses( - messages: try session.transcript.toOpenAIMessages(), + messages: session.transcript.toOpenAIMessages(), tools: openAITools, generating: type, schema: schema, @@ -779,7 +779,7 @@ public struct OpenAILanguageModel: LanguageModel { continuation in let task = Task { do { - var messages = try session.transcript.toOpenAIMessages() + var messages = session.transcript.toOpenAIMessages() var state = StreamingResponseState() var toolRounds = ToolRoundLimit(provider: "OpenAI") while true { @@ -1320,7 +1320,7 @@ private enum Responses { // MARK: - Supporting Types extension Transcript { - fileprivate func toOpenAIMessages() throws -> [OpenAIMessage] { + fileprivate func toOpenAIMessages() -> [OpenAIMessage] { var messages = [OpenAIMessage]() for item in self { switch item { @@ -1339,7 +1339,8 @@ extension Transcript { ) ) case .reasoning: - throw Transcript.ReasoningReplayError.unsupportedProvider("OpenAILanguageModel") + // 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 a942af32..265e063b 100644 --- a/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift @@ -435,7 +435,7 @@ public struct OpenResponsesLanguageModel: LanguageModel { let tools: [OpenResponsesTool]? = session.tools.isEmpty ? nil : session.tools.map { convertToolToOpenResponsesFormat($0) } return try await respondWithOpenResponses( - messages: try session.transcript.toOpenResponsesMessages(), + messages: session.transcript.toOpenResponsesMessages(), tools: tools, generating: type, schema: schema, @@ -493,7 +493,7 @@ public struct OpenResponsesLanguageModel: LanguageModel { continuation in let task = Task { do { - var messages = try session.transcript.toOpenResponsesMessages() + var messages = session.transcript.toOpenResponsesMessages() var state = StreamingResponseState() var toolRounds = ToolRoundLimit(provider: "Open Responses") while true { @@ -904,7 +904,7 @@ private enum OpenResponsesBlock: Sendable { } extension Transcript { - fileprivate func toOpenResponsesMessages() throws -> [OpenResponsesMessage] { + fileprivate func toOpenResponsesMessages() -> [OpenResponsesMessage] { var list: [OpenResponsesMessage] = [] for item in self { switch item { @@ -923,7 +923,8 @@ extension Transcript { ) ) case .reasoning: - throw Transcript.ReasoningReplayError.unsupportedProvider("OpenResponsesLanguageModel") + // 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 60399d45..64adfc6c 100644 --- a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift @@ -131,13 +131,12 @@ let fmSession = FoundationModels.LanguageModelSession( model: systemModel, tools: session.tools.toFoundationModels(), - transcript: try fmTranscriptDroppingDuplicatePrompt(session.transcript, prompt: prompt) - .toFoundationModels( - instructions: session.instructions, - toolDefinitions: session.tools - .filter(\.includesSchemaInInstructions) - .map { Transcript.ToolDefinition(tool: $0) } - ) + transcript: fmTranscriptDroppingDuplicatePrompt(session.transcript, prompt: prompt).toFoundationModels( + instructions: session.instructions, + toolDefinitions: session.tools + .filter(\.includesSchemaInInstructions) + .map { Transcript.ToolDefinition(tool: $0) } + ) ) return try await fmRespond( @@ -195,30 +194,25 @@ let fmPrompt = prompt.toFoundationModels() let fmOptions = options.toFoundationModels() - do { - let fmSession = FoundationModels.LanguageModelSession( - model: systemModel, - tools: session.tools.toFoundationModels(), - transcript: try fmTranscriptDroppingDuplicatePrompt(session.transcript, prompt: prompt) - .toFoundationModels( - instructions: session.instructions, - toolDefinitions: session.tools - .filter(\.includesSchemaInInstructions) - .map { Transcript.ToolDefinition(tool: $0) } - ) + let fmSession = FoundationModels.LanguageModelSession( + model: systemModel, + tools: session.tools.toFoundationModels(), + transcript: fmTranscriptDroppingDuplicatePrompt(session.transcript, prompt: prompt).toFoundationModels( + instructions: session.instructions, + toolDefinitions: session.tools + .filter(\.includesSchemaInInstructions) + .map { Transcript.ToolDefinition(tool: $0) } ) + ) - return fmStreamResponse( - makeSession: { fmSession }, - fmPrompt: fmPrompt, - fmOptions: fmOptions, - type: type, - schema: schema, - includeSchemaInPrompt: includeSchemaInPrompt - ) - } catch { - return .init(stream: AsyncThrowingStream { $0.finish(throwing: error) }) - } + return fmStreamResponse( + makeSession: { fmSession }, + fmPrompt: fmPrompt, + fmOptions: fmOptions, + type: type, + schema: schema, + includeSchemaInPrompt: includeSchemaInPrompt + ) } public func logFeedbackAttachment( @@ -574,7 +568,7 @@ func toFoundationModels( instructions: AnyLanguageModel.Instructions?, toolDefinitions: [Transcript.ToolDefinition] - ) throws -> FoundationModels.Transcript { + ) -> FoundationModels.Transcript { var fmEntries: [FoundationModels.Transcript.Entry] = [] // Add instructions entry if provided and not already in transcript @@ -613,7 +607,8 @@ fmEntries.append(.prompt(fmPrompt)) case .reasoning: - throw Transcript.ReasoningReplayError.unsupportedProvider("SystemLanguageModel") + // 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/Tests/AnyLanguageModelTests/AnthropicReasoningTests.swift b/Tests/AnyLanguageModelTests/AnthropicReasoningTests.swift index 8bf03de6..8506ba9e 100644 --- a/Tests/AnyLanguageModelTests/AnthropicReasoningTests.swift +++ b/Tests/AnyLanguageModelTests/AnthropicReasoningTests.swift @@ -121,14 +121,151 @@ import Testing #expect(blocks.first?["data"] as? String == "opaque-redacted") } - @Test func unsupportedReplayFailsBeforeNetwork() async throws { + @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(segments: [.text(.init(content: "Other provider"))])) + .reasoning( + .init( + metadata: ["provider": GeneratedContent("anthropic")], + segments: [.text(.init(content: "Native reasoning"))] + ) + ) ]) let session = LanguageModelSession(model: model(), transcript: transcript) - await #expect(throws: Transcript.ReasoningReplayError.self) { _ = try await session.respond(to: "Next") } + 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 { diff --git a/Tests/AnyLanguageModelTests/ReasoningTests.swift b/Tests/AnyLanguageModelTests/ReasoningTests.swift index 66d78afb..b66fca34 100644 --- a/Tests/AnyLanguageModelTests/ReasoningTests.swift +++ b/Tests/AnyLanguageModelTests/ReasoningTests.swift @@ -18,8 +18,8 @@ struct ReasoningTests { #expect(!transcript.first!.description.contains("255")) } - @available(macOS 26.0, iOS 26.0, *) @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: