From afc2f3b97407fd2fd00221e76a3beeed15e1ac2e Mon Sep 17 00:00:00 2001 From: Taylor Lineman Date: Thu, 2 Jul 2026 13:49:45 -0400 Subject: [PATCH 01/11] Added adaptive thinking to AnthropicLanguageModel Signed-off-by: Taylor Lineman --- .../Models/AnthropicLanguageModel.swift | 84 ++++++++++++++++--- 1 file changed, 73 insertions(+), 11 deletions(-) diff --git a/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift b/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift index d93c987..43e59d0 100644 --- a/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift @@ -98,6 +98,8 @@ public struct AnthropicLanguageModel: LanguageModel { /// These parameters are merged into the top-level request JSON, /// allowing you to pass additional options not explicitly modeled. public var extraBody: [String: JSONValue]? + + public var effort: Effort? // MARK: - Nested Types @@ -178,22 +180,51 @@ public struct AnthropicLanguageModel: LanguageModel { } } + /// How much effort the model should put into a task. + /// + /// Docs: https://platform.claude.com/docs/en/build-with-claude/effort + public enum Effort: String, Hashable, Codable, Sendable { + /// Absolute maximum capability with no constraints on token spending. + /// + /// Use Case: Tasks requiring the deepest possible reasoning and most thorough analysis + /// Availability: Claude Fable 5, Claude Mythos 5, Claude Opus 4.8, Claude Mythos Preview, Claude Opus 4.7, Claude Opus 4.6, Claude Sonnet 5, and Claude Sonnet 4.6. + case max + /// Extended capability for long-horizon work. + /// Use Case: Long-running agentic and coding tasks (over 30 minutes) with token budgets in the millions + /// Availability: Claude Fable 5, Claude Mythos 5, Claude Opus 4.8, Claude Opus 4.7, and Claude Sonnet 5. + case extraHigh = "xHigh" + /// High capability. Equivalent to not setting the parameter. + /// Use Case: Complex reasoning, difficult coding problems, agentic tasks + /// Availability: All Models + case high + /// Balanced approach with moderate token savings. + /// Use Case: Agentic tasks that require a balance of speed, cost, and performance + /// Availability: All Models + case medium + /// Most efficient. Significant token savings with some capability reduction. + /// Use Case: Simpler tasks that need the best speed and lowest costs, like subagents + /// Availability: All Models + case low + } + /// Configuration for extended thinking. public struct Thinking: Hashable, Codable, Sendable { /// The type of thinking to use. public var type: ThinkingType - /// The maximum number of tokens to use for thinking. + /// The maximum number of tokens to use for thinking. Nil when `type` = `.adaptive`. /// /// This budget is the maximum number of tokens the model can use for its /// internal reasoning process. Larger budgets can improve response quality /// for complex tasks but increase latency and cost. - public var budgetTokens: Int + public var budgetTokens: Int? /// The type of thinking mode. public enum ThinkingType: String, Hashable, Codable, Sendable { /// Enables extended thinking. case enabled + /// Enables adaptive thinking. + case adaptive } enum CodingKeys: String, CodingKey { @@ -202,12 +233,24 @@ public struct AnthropicLanguageModel: LanguageModel { } /// Creates a thinking configuration. - /// - /// - Parameter budgetTokens: The maximum number of tokens to use for thinking. - public init(budgetTokens: Int) { - self.type = .enabled + /// + /// - Parameters: + /// - type: The type of thinking to perform. + /// - budgetTokens: The maximum number of tokens to use for thinking. + public init(type: ThinkingType, budgetTokens: Int?) { + self.type = type self.budgetTokens = budgetTokens } + + /// Convenience function for enabling adaptive thinking on supported models. + public static func adaptive() -> Thinking { + return Thinking.init(type: .adaptive, budgetTokens: nil) + } + + /// Convenience function for enabling thinking with a token budget on supported models. + public static func enabled(budgetTokens: Int) -> Thinking { + return Thinking.init(type: .enabled, budgetTokens: budgetTokens) + } } /// The tier of service for processing the request. @@ -241,7 +284,8 @@ public struct AnthropicLanguageModel: LanguageModel { toolChoice: ToolChoice? = nil, thinking: Thinking? = nil, serviceTier: ServiceTier? = nil, - extraBody: [String: JSONValue]? = nil + extraBody: [String: JSONValue]? = nil, + effort: Effort? = nil ) { self.topP = topP self.topK = topK @@ -251,6 +295,7 @@ public struct AnthropicLanguageModel: LanguageModel { self.thinking = thinking self.serviceTier = serviceTier self.extraBody = extraBody + self.effort = effort } } /// The reason the model is unavailable. @@ -577,11 +622,28 @@ private func createMessageParams( params["tool_choice"] = .object(["type": .string("none")]) } } + if let effort = customOptions.effort { + // If output_config was previously set during the response schema options, we need to append insert into that dictionary instead of replacing it. + if let output_config = params["output_config"], var object = output_config.objectValue { + object["effort"] = .string(effort.rawValue) + params["output_config"] = .object(object) + } else { + params["output_config"] = .object( + [ + "effort": .string(effort.rawValue) + ] + ) + } + } if let thinking = customOptions.thinking { - params["thinking"] = .object([ - "type": .string(thinking.type.rawValue), - "budget_tokens": .int(thinking.budgetTokens), - ]) + var thinkingObject: [String: JSONValue] = [ + "type": .string(thinking.type.rawValue) + ] + if let budget = thinking.budgetTokens { + thinkingObject["budget_tokens"] = .int(budget) + } + + params["thinking"] = .object(thinkingObject) } if let serviceTier = customOptions.serviceTier { params["service_tier"] = .string(serviceTier.rawValue) From 870639f672ef5c5e0c96021541a2d7d7f66683a1 Mon Sep 17 00:00:00 2001 From: Taylor Lineman Date: Thu, 2 Jul 2026 14:08:13 -0400 Subject: [PATCH 02/11] Add display value to thinking Signed-off-by: Taylor Lineman --- .../Models/AnthropicLanguageModel.swift | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift b/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift index 43e59d0..40d31a1 100644 --- a/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift @@ -218,6 +218,9 @@ public struct AnthropicLanguageModel: LanguageModel { /// internal reasoning process. Larger budgets can improve response quality /// for complex tasks but increase latency and cost. public var budgetTokens: Int? + + /// How thinking should be displayed. + public var display: ThinkingDisplay? /// The type of thinking mode. public enum ThinkingType: String, Hashable, Codable, Sendable { @@ -226,6 +229,13 @@ public struct AnthropicLanguageModel: LanguageModel { /// Enables adaptive thinking. case adaptive } + + public enum ThinkingDisplay: String, Hashable, Codable, Sendable { + /// Thinking will be summarized. + case summarized + /// No thoughts will be returned. + case omitted + } enum CodingKeys: String, CodingKey { case type @@ -237,19 +247,20 @@ public struct AnthropicLanguageModel: LanguageModel { /// - Parameters: /// - type: The type of thinking to perform. /// - budgetTokens: The maximum number of tokens to use for thinking. - public init(type: ThinkingType, budgetTokens: Int?) { + public init(type: ThinkingType, budgetTokens: Int?, display: ThinkingDisplay?) { self.type = type self.budgetTokens = budgetTokens + self.display = display } /// Convenience function for enabling adaptive thinking on supported models. - public static func adaptive() -> Thinking { - return Thinking.init(type: .adaptive, budgetTokens: nil) + public static func adaptive(display: ThinkingDisplay?) -> Thinking { + return Thinking.init(type: .adaptive, budgetTokens: nil, display: display) } /// Convenience function for enabling thinking with a token budget on supported models. - public static func enabled(budgetTokens: Int) -> Thinking { - return Thinking.init(type: .enabled, budgetTokens: budgetTokens) + public static func enabled(budgetTokens: Int, display: ThinkingDisplay?) -> Thinking { + return Thinking.init(type: .enabled, budgetTokens: budgetTokens, display: display) } } @@ -642,6 +653,9 @@ private func createMessageParams( if let budget = thinking.budgetTokens { thinkingObject["budget_tokens"] = .int(budget) } + if let display = thinking.display { + thinkingObject["display"] = .string(display.rawValue) + } params["thinking"] = .object(thinkingObject) } From 0813b9f539f9c169578b4481f86cfd2945c4aec0 Mon Sep 17 00:00:00 2001 From: Taylor Lineman Date: Thu, 2 Jul 2026 14:14:45 -0400 Subject: [PATCH 03/11] Addede support for thinking deltas Signed-off-by: Taylor Lineman --- .../Models/AnthropicLanguageModel.swift | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift b/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift index 40d31a1..4845bce 100644 --- a/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift @@ -1194,6 +1194,8 @@ private enum AnthropicStreamEvent: Codable, Sendable { enum Delta: Codable, Sendable { case textDelta(TextDelta) case inputJsonDelta(InputJsonDelta) + case thinkingDelta(ThinkingDelta) + case signatureDelta(SignatureDelta) case ignored enum CodingKeys: String, CodingKey { case type } @@ -1207,6 +1209,10 @@ private enum AnthropicStreamEvent: Codable, Sendable { self = .textDelta(try TextDelta(from: decoder)) case "input_json_delta": self = .inputJsonDelta(try InputJsonDelta(from: decoder)) + case "thinking_delta": + self = .thinkingDelta(try ThinkingDelta(from: decoder)) + case "signature_delta": + self = .signatureDelta(try SignatureDelta(from: decoder)) default: self = .ignored } @@ -1216,6 +1222,8 @@ private enum AnthropicStreamEvent: Codable, Sendable { switch self { case .textDelta(let delta): try delta.encode(to: encoder) case .inputJsonDelta(let delta): try delta.encode(to: encoder) + case .thinkingDelta(let delta): try delta.encode(to: encoder) + case .signatureDelta(let delta): try delta.encode(to: encoder) case .ignored: var container = encoder.container(keyedBy: CodingKeys.self) try container.encode("ignored", forKey: .type) @@ -1236,6 +1244,20 @@ private enum AnthropicStreamEvent: Codable, Sendable { case partialJson = "partial_json" } } + + struct ThinkingDelta: Codable, SendableMetatype { + let type: String + let thinking: String + } + + /// Cryptographic signature for a completed thinking block. + /// + /// Emitted at the end of a thinking block, even when ``CustomGenerationOptions/Thinking/display`` is set to `omitted`. + /// The signature must be preserved verbatim for thought to be recovered in the transcript. Otherwise the Claude API will throw out any text provided in thinking blocks. + struct SignatureDelta: Codable, Sendable { + let type: String + let signature: String + } } } From 95668ce74de82bc7409cec35f229c103a8032168 Mon Sep 17 00:00:00 2001 From: Taylor Lineman Date: Thu, 2 Jul 2026 16:12:16 -0400 Subject: [PATCH 04/11] Proper tool responses in the respond function. Previously, tool use would cancel the response. This meant that the model would never get to use the result of its tools. This fix adds an infinite loop similair to the OpenAI implementation Signed-off-by: Taylor Lineman --- .../Models/AnthropicLanguageModel.swift | 137 +++++++++++------- 1 file changed, 84 insertions(+), 53 deletions(-) diff --git a/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift b/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift index 4845bce..f812201 100644 --- a/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift @@ -380,73 +380,102 @@ public struct AnthropicLanguageModel: LanguageModel { let anthropicTools: [AnthropicTool] = try session.tools.map { tool in try convertToolToAnthropicFormat(tool) } - + let responseSchema = type == String.self ? nil : try convertSchemaToAnthropicFormat(Content.generationSchema) - let params = try createMessageParams( - model: model, - system: nil, - messages: session.transcript.toAnthropicMessages(), - tools: anthropicTools.isEmpty ? nil : anthropicTools, - responseSchema: responseSchema, - options: options - ) - - let body = try JSONEncoder().encode(params) - - let message: AnthropicMessageResponse = try await httpSession.fetch( - .post, - url: url, - headers: headers, - body: body - ) - + var entries: [Transcript.Entry] = [] + var runningText = "" + var lastOutput: [AnthropicContent]? + var messages: [AnthropicMessage] = session.transcript.toAnthropicMessages() + + // Loop until no more tool calls are found. + while true { + let params = try createMessageParams( + model: model, + system: nil, + messages: messages, + tools: anthropicTools.isEmpty ? nil : anthropicTools, + responseSchema: responseSchema, + options: options + ) + + let body = try JSONEncoder().encode(params) - // Handle tool calls, if present - let toolUses: [AnthropicToolUse] = message.content.compactMap { block in - if case .toolUse(let u) = block { return u } - return nil - } + let message: AnthropicMessageResponse = try await httpSession.fetch( + .post, + url: url, + headers: headers, + body: body + ) - if !toolUses.isEmpty { - let resolution = try await resolveToolUses(toolUses, session: session) - switch resolution { - case .stop(let calls): - if !calls.isEmpty { - entries.append(.toolCalls(Transcript.ToolCalls(calls))) - } - let empty = try emptyResponseContent(for: type) - return LanguageModelSession.Response( - content: empty.content, - rawContent: empty.rawContent, - transcriptEntries: ArraySlice(entries) - ) - case .invocations(let invocations): - if !invocations.isEmpty { - entries.append(.toolCalls(Transcript.ToolCalls(invocations.map(\.call)))) - for invocation in invocations { - entries.append(.toolOutput(invocation.output)) + // Append to messages for future response loops. + messages.append(AnthropicMessage(role: .assistant, content: message.content)) + + // Handle tool calls, if present + let toolUses: [AnthropicToolUse] = message.content.compactMap { content in + if case .toolUse(let u) = content { return u } + return nil + } + lastOutput = message.content + + if !toolUses.isEmpty { + let resolution = try await resolveToolUses(toolUses, session: session) + switch resolution { + case .stop(let calls): + if !calls.isEmpty { + entries.append(.toolCalls(Transcript.ToolCalls(calls))) + } + let empty = try emptyResponseContent(for: type) + return LanguageModelSession.Response( + content: empty.content, + rawContent: empty.rawContent, + transcriptEntries: ArraySlice(entries) + ) + case .invocations(let invocations): + if !invocations.isEmpty { + var toolResultBlocks: [AnthropicContent] = [] + + for invocation in invocations { + entries.append(.toolOutput(invocation.output)) + toolResultBlocks.append( + .toolResult( + AnthropicToolResult( + toolUseId: invocation.call.id, + content: convertSegmentsToAnthropicContent(invocation.output.segments) + ) + ) + ) + } + + messages.append(AnthropicMessage(role: .user, content: toolResultBlocks)) + entries.append(.toolCalls(Transcript.ToolCalls(invocations.map(\.call)))) + + continue // Keep going through the loop } } } - } - let text = message.content.compactMap { block -> String? in - switch block { - case .text(let t): return t.text - default: return nil - } - }.joined() + // If we make it here, no tools were called and we can assume this is the last message. + + runningText = message.content.compactMap { block -> String? in + switch block { + case .text(let t): return t.text + default: return nil + } + }.joined() + + break // Break the loop + } if type == String.self { return LanguageModelSession.Response( - content: text as! Content, - rawContent: GeneratedContent(text), + content: runningText as! Content, + rawContent: GeneratedContent(runningText), transcriptEntries: ArraySlice(entries) ) } - let rawContent = try GeneratedContent(json: text) + let rawContent = try GeneratedContent(json: runningText) let content = try Content(rawContent) return LanguageModelSession.Response( content: content, @@ -979,10 +1008,12 @@ private enum AnthropicContent: Codable, Sendable { private struct AnthropicThinking: Codable, Sendable { let type: String let thinking: String + let signature: String - init(thinking: String) { + init(thinking: String, signature: String) { self.type = "thinking" self.thinking = thinking + self.signature = signature } } From 387daca82201cedec8f83d687eff68af43a08469 Mon Sep 17 00:00:00 2001 From: Taylor Lineman Date: Thu, 2 Jul 2026 16:16:23 -0400 Subject: [PATCH 05/11] Add an actual stream parameter for createMessageParams Signed-off-by: Taylor Lineman --- .../Models/AnthropicLanguageModel.swift | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift b/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift index f812201..bd50c83 100644 --- a/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift @@ -506,15 +506,16 @@ public struct AnthropicLanguageModel: LanguageModel { let responseSchema = type == String.self ? nil : try convertSchemaToAnthropicFormat(Content.generationSchema) + var params = try createMessageParams( model: model, system: nil, messages: session.transcript.toAnthropicMessages(), tools: anthropicTools.isEmpty ? nil : anthropicTools, responseSchema: responseSchema, - options: options + options: options, + stream: true ) - params["stream"] = .bool(true) let body = try JSONEncoder().encode(params) @@ -591,7 +592,8 @@ private func createMessageParams( messages: [AnthropicMessage], tools: [AnthropicTool]?, responseSchema: JSONSchema?, - options: GenerationOptions + options: GenerationOptions, + stream: Bool? = nil ) throws -> [String: JSONValue] { var params: [String: JSONValue] = [ "model": .string(model), @@ -698,6 +700,10 @@ private func createMessageParams( params[key] = value } } + + if let stream { + params["stream"] = .bool(stream) + } } return params From ed26973d4744687b2df2f8702a4aba5bd97700bf Mon Sep 17 00:00:00 2001 From: Taylor Lineman Date: Thu, 2 Jul 2026 18:31:53 -0400 Subject: [PATCH 06/11] Basic transcription streaming Signed-off-by: Taylor Lineman --- .../LanguageModelSession.swift | 14 +- .../Models/AnthropicLanguageModel.swift | 270 ++++++++++++++---- Sources/AnyLanguageModel/Transcript.swift | 28 ++ 3 files changed, 260 insertions(+), 52 deletions(-) diff --git a/Sources/AnyLanguageModel/LanguageModelSession.swift b/Sources/AnyLanguageModel/LanguageModelSession.swift index 5ac3337..2ff1291 100644 --- a/Sources/AnyLanguageModel/LanguageModelSession.swift +++ b/Sources/AnyLanguageModel/LanguageModelSession.swift @@ -123,7 +123,19 @@ public final class LanguageModelSession: @unchecked Sendable { state.withLock { $0.endResponding() } } } - + + nonisolated func growStreamingTranscript(text: String) { + withMutation(keyPath: \.transcript) { + state.withLock { $0.transcript.appendStreamingResponse(text) } + } + } + + nonisolated func appendTranscriptEntry(_ entry: Transcript.Entry) { + withMutation(keyPath: \.transcript) { + state.withLock { $0.transcript.append(entry) } + } + } + nonisolated private func wrapRespond(_ operation: () async throws -> T) async throws -> T { beginResponding() do { diff --git a/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift b/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift index bd50c83..a9ea25b 100644 --- a/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift @@ -456,7 +456,6 @@ public struct AnthropicLanguageModel: LanguageModel { } // If we make it here, no tools were called and we can assume this is the last message. - runningText = message.content.compactMap { block -> String? in switch block { case .text(let t): return t.text @@ -483,6 +482,20 @@ public struct AnthropicLanguageModel: LanguageModel { transcriptEntries: ArraySlice(entries) ) } + + + struct ContentAccumulationBlocks: Hashable, Codable, Sendable { + enum Kind: Hashable, Codable, Sendable { case toolUse, thinking, text } + + var kind: Kind + var text: String + var partialJSON: String? + + // Used by tool use content blocks + var id: String? + var name: String? + var signature: String? + } public func streamResponse( within session: LanguageModelSession, @@ -504,61 +517,198 @@ public struct AnthropicLanguageModel: LanguageModel { try convertToolToAnthropicFormat(tool) } - let responseSchema = - type == String.self ? nil : try convertSchemaToAnthropicFormat(Content.generationSchema) + let responseSchema = type == String.self ? nil : try convertSchemaToAnthropicFormat(Content.generationSchema) + let expectsStructuredResponse = type != String.self - var params = try createMessageParams( - model: model, - system: nil, - messages: session.transcript.toAnthropicMessages(), - tools: anthropicTools.isEmpty ? nil : anthropicTools, - responseSchema: responseSchema, - options: options, - stream: true - ) - - let body = try JSONEncoder().encode(params) - - // Stream server-sent events from Anthropic API - let events: AsyncThrowingStream = - httpSession - .fetchEventStream( - .post, - url: url, - headers: headers, - body: body + var messages: [AnthropicMessage] = session.transcript.toAnthropicMessages() + + while true { + var accumulatedText: String = "" + var stopReason: String? = nil + + var params = try createMessageParams( + model: model, + system: nil, + messages: messages, + tools: anthropicTools.isEmpty ? nil : anthropicTools, + responseSchema: responseSchema, + options: options, + stream: true ) + + let body = try JSONEncoder().encode(params) + + // Stream server-sent events from Anthropic API + let events: AsyncThrowingStream = + httpSession + .fetchEventStream( + .post, + url: url, + headers: headers, + body: body + ) + + // Accumulating content blocks keyed by their index. + var contentBlocks: [Int: ContentAccumulationBlocks] = [:] + + eventStream: for try await event in events { + switch event { + case .contentBlockStart(let start): + // Create blocks at their index + switch start.contentBlock.type { + case "tool_use": + contentBlocks[start.index] = ContentAccumulationBlocks( + kind: .toolUse, + text: start.contentBlock.text ?? "", + id: start.contentBlock.id, + name: start.contentBlock.name + ) + case "thinking": + contentBlocks[start.index] = ContentAccumulationBlocks( + kind: .thinking, + text: start.contentBlock.text ?? "" + ) + default: + contentBlocks[start.index] = ContentAccumulationBlocks( + kind: .text, + text: start.contentBlock.text ?? "" + ) + } + case .contentBlockDelta(let delta): + switch delta.delta { + case .textDelta(let textDelta): + // Accumulate text delta for streaming + // Make sure the block has even been started. + guard contentBlocks[delta.index] != nil else { continue } + + // Set default text + if contentBlocks[delta.index]?.text == nil { + contentBlocks[delta.index]?.text = "" + } + + contentBlocks[delta.index]?.text += textDelta.text + accumulatedText += textDelta.text + + // Grow the observable transcript so a Transcript-driven UI updates live. + session.growStreamingTranscript(text: accumulatedText) + + // Send text back normally + if expectsStructuredResponse { + if let snapshot: LanguageModelSession.ResponseStream.Snapshot = + try? partialSnapshot(from: accumulatedText) + { + continuation.yield(snapshot) + } + } else { + let raw = GeneratedContent(accumulatedText) + let content: Content.PartiallyGenerated = (accumulatedText as! Content) + .asPartiallyGenerated() + continuation.yield(.init(content: content, rawContent: raw)) + } + case .inputJsonDelta(let jsonDelta): + if contentBlocks[delta.index]?.partialJSON == nil { + contentBlocks[delta.index]?.partialJSON = "" + } + + contentBlocks[delta.index]?.partialJSON? += jsonDelta.partialJson + case .thinkingDelta(let thinkingDelta): + contentBlocks[delta.index]?.text += thinkingDelta.thinking + case .signatureDelta(let signatureDelta): + if contentBlocks[delta.index]?.signature == nil { + contentBlocks[delta.index]?.signature = "" + } - var accumulatedText = "" - let expectsStructuredResponse = type != String.self - - for try await event in events { - switch event { - case .contentBlockDelta(let delta): - if case .textDelta(let textDelta) = delta.delta { - accumulatedText += textDelta.text - - if expectsStructuredResponse { - if let snapshot: LanguageModelSession.ResponseStream.Snapshot = - try? partialSnapshot(from: accumulatedText) - { - continuation.yield(snapshot) + contentBlocks[delta.index]?.signature? += signatureDelta.signature + case .ignored: + break + } + case .messageDelta(let messageDelta): + stopReason = messageDelta.delta.stopReason + case .messageStop: + // Need to use a label, otherwise this would break out of the switch. + break eventStream + case .ping, .ignored, .messageStart, .messageStop, .contentBlockStop: + continue + } + } + + // Assemble assistant content from the streamed content blocks + var assistantContent: [AnthropicContent] = [] + var toolUses: [AnthropicToolUse] = [] + + for block in contentBlocks.sorted(by: { $0.key < $1.key }).map(\.value) { + switch block.kind { + case .text: + assistantContent.append( + AnthropicContent.text(AnthropicText(text: block.text)) + ) + case .thinking: + // Ensure there is a signature. Needed for claude to reconstruct the thought on the server. + guard let signature = block.signature else { continue } + + assistantContent.append( + AnthropicContent.thinking(AnthropicThinking(thinking: block.text, signature: signature)) + ) + case .toolUse: + guard let id = block.id, let name = block.name, let jsonString = block.partialJSON else { continue } + guard let json = fromPartialJSON(jsonString) else { continue } + + let toolUse = AnthropicToolUse(id: id, name: name, input: json) + assistantContent.append(AnthropicContent.toolUse(toolUse)) + toolUses.append(toolUse) + } + } + + messages.append(AnthropicMessage(role: .assistant, content: assistantContent)) + + // Process the tool calls + var appendedToolResults = false + if !toolUses.isEmpty { + let resolution = try await resolveToolUses(toolUses, session: session) + switch resolution { + case .stop(let calls): + if !calls.isEmpty { + session.appendTranscriptEntry(.toolCalls(Transcript.ToolCalls(calls))) + } + continuation.finish() + return + case .invocations(let invocations): + if !invocations.isEmpty { + var toolResultBlocks: [AnthropicContent] = [] + + for invocation in invocations { + // Save the tool outputs into the transcript. + session.appendTranscriptEntry(.toolOutput(invocation.output)) + + // + toolResultBlocks.append( + .toolResult( + AnthropicToolResult( + toolUseId: invocation.call.id, + content: convertSegmentsToAnthropicContent(invocation.output.segments) + ) + ) + ) } - } else { - let raw = GeneratedContent(accumulatedText) - let content: Content.PartiallyGenerated = (accumulatedText as! Content) - .asPartiallyGenerated() - continuation.yield(.init(content: content, rawContent: raw)) + + messages.append(AnthropicMessage(role: .user, content: toolResultBlocks)) + session.appendTranscriptEntry( + .toolCalls(Transcript.ToolCalls(invocations.map(\.call))) + ) + + appendedToolResults = true } } - case .messageStop: - continuation.finish() - return - case .messageStart, .contentBlockStart, .contentBlockStop, .messageDelta, .ping, .ignored: + } + + // Continue only if we responded with tool call results, if we didn't the turn is complete. + if stopReason == "tool_use" && appendedToolResults { + continue + } else { break } } - + continuation.finish() } catch { continuation.finish(throwing: error) @@ -700,10 +850,10 @@ private func createMessageParams( params[key] = value } } - - if let stream { - params["stream"] = .bool(stream) - } + } + + if let stream { + params["stream"] = .bool(stream) } return params @@ -884,6 +1034,19 @@ private func fromGeneratedContent(_ content: GeneratedContent) throws -> [String return dict } +private func fromPartialJSON(_ json: String) -> [String: JSONValue]? { + let content = json.trimmingCharacters(in: .whitespacesAndNewlines) + guard !content.isEmpty else { return [:] } + guard let data = content.data(using: .utf8) else { return nil } + guard let jsonValue = try? JSONDecoder().decode(JSONValue.self, from: data) else { return nil } + + guard case .object(let dict) = jsonValue else { + return nil + } + return dict +} + + // MARK: - Supporting Types extension Transcript { @@ -1220,6 +1383,11 @@ private enum AnthropicStreamEvent: Codable, Sendable { struct ContentBlock: Codable, Sendable { let type: String let text: String? + + // Used by tool use content blocks. + let id: String? + let name: String? + let input: [String: JSONValue]? } } diff --git a/Sources/AnyLanguageModel/Transcript.swift b/Sources/AnyLanguageModel/Transcript.swift index dcd1fad..6182ff3 100644 --- a/Sources/AnyLanguageModel/Transcript.swift +++ b/Sources/AnyLanguageModel/Transcript.swift @@ -21,6 +21,34 @@ public struct Transcript: Sendable, Equatable, Codable { mutating func append(contentsOf newEntries: S) where S: Sequence, S.Element == Entry { entries.append(contentsOf: newEntries) } + + mutating private func replace(index: Int, with entry: Entry) { + entries[index] = entry + } + + + /// Updates a transcript with temporary text that is being streamed from a model. + /// Appends the assistant response to the end of entries, if the last entry is a response then that response is updated with the newest streamed text. + /// + /// - Parameter text: The text to update the transcript with. + mutating func appendStreamingResponse(_ text: String) { + let textSegment = Transcript.Segment.text(Transcript.TextSegment(content: text)) + // Make sure the last entry in the transcript is a response. If it is not, create a new response and append it to the end of the transcript. + guard case .response(var response) = entries.last else { + append(Entry.response(Response(assetIDs: [], segments: [textSegment]))) + return + } + + // If the last segment in the last response is text, replace it with the new content. + if case .text(let last)? = response.segments.last { + response.segments[response.segments.count - 1] = textSegment + } else { + response.segments.append(textSegment) + } + + // Replace the latest entry with the one we just updated. + replace(index: entries.count - 1, with: .response(response)) + } /// An entry in a transcript. public enum Entry: Sendable, Identifiable, Equatable, Codable { From 65ba539269f198d01f7723ca2c9b6f304ce02305 Mon Sep 17 00:00:00 2001 From: Taylor Lineman Date: Sun, 5 Jul 2026 13:36:46 -0400 Subject: [PATCH 07/11] Support streaming transcript in Anthropic models This also brings tool support in the streaming endpoint for anthropic models Signed-off-by: Taylor Lineman --- .../LanguageModelSession.swift | 8 +------- Sources/AnyLanguageModel/Transcript.swift | 20 ++++++++++++++++++- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/Sources/AnyLanguageModel/LanguageModelSession.swift b/Sources/AnyLanguageModel/LanguageModelSession.swift index 2ff1291..a448431 100644 --- a/Sources/AnyLanguageModel/LanguageModelSession.swift +++ b/Sources/AnyLanguageModel/LanguageModelSession.swift @@ -175,14 +175,8 @@ public final class LanguageModelSession: @unchecked Sendable { textContent = lastSnapshot.rawContent.jsonString } - let responseEntry = Transcript.Entry.response( - Transcript.Response( - assetIDs: [], - segments: [.text(.init(content: textContent))] - ) - ) session.withMutation(keyPath: \.transcript) { - session.state.withLock { $0.transcript.append(responseEntry) } + session.state.withLock { $0.transcript.finalizeStreamedTranscript(textContent, assetIDs: []) } } } } catch { diff --git a/Sources/AnyLanguageModel/Transcript.swift b/Sources/AnyLanguageModel/Transcript.swift index 6182ff3..6b9bce2 100644 --- a/Sources/AnyLanguageModel/Transcript.swift +++ b/Sources/AnyLanguageModel/Transcript.swift @@ -26,7 +26,6 @@ public struct Transcript: Sendable, Equatable, Codable { entries[index] = entry } - /// Updates a transcript with temporary text that is being streamed from a model. /// Appends the assistant response to the end of entries, if the last entry is a response then that response is updated with the newest streamed text. /// @@ -49,6 +48,25 @@ public struct Transcript: Sendable, Equatable, Codable { // Replace the latest entry with the one we just updated. replace(index: entries.count - 1, with: .response(response)) } + + /// Replaces the trailing response entry's text with the final text, or appends a new response entry if the last entry isn't a response. + /// Prevents streamed responses from having duplicate entries on completion. + /// + /// - Parameters: + /// - text: The text to replace the final response with. + /// - assetIDs: The assetIDs for the response. + mutating func finalizeStreamedTranscript(_ text: String, assetIDs: [String]) { + let textSegment = Transcript.Segment.text(Transcript.TextSegment(content: text)) + // Make sure the last entry in the transcript is a response. If it is not, create a new response and append it to the end of the transcript. + guard case .response(var response) = entries.last else { + append(Entry.response(Response(assetIDs: assetIDs, segments: [textSegment]))) + return + } + + // Replace the latest entry with the one we just updated. + replace(index: entries.count - 1, with: .response(Response(id: response.id, assetIDs: assetIDs, segments: [textSegment]))) + } + /// An entry in a transcript. public enum Entry: Sendable, Identifiable, Equatable, Codable { From 5061e7493b1273825c239d51a279d6b31136fc4b Mon Sep 17 00:00:00 2001 From: Taylor Lineman Date: Mon, 6 Jul 2026 10:29:01 -0400 Subject: [PATCH 08/11] Fixed tool call - output ordering in transcript Signed-off-by: Taylor Lineman --- .../Models/AnthropicLanguageModel.swift | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift b/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift index a9ea25b..ceba133 100644 --- a/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift @@ -676,11 +676,15 @@ public struct AnthropicLanguageModel: LanguageModel { if !invocations.isEmpty { var toolResultBlocks: [AnthropicContent] = [] + // Need to append tool calls before tool results + session.appendTranscriptEntry( + .toolCalls(Transcript.ToolCalls(invocations.map(\.call))) + ) + for invocation in invocations { // Save the tool outputs into the transcript. session.appendTranscriptEntry(.toolOutput(invocation.output)) - // toolResultBlocks.append( .toolResult( AnthropicToolResult( @@ -692,10 +696,6 @@ public struct AnthropicLanguageModel: LanguageModel { } messages.append(AnthropicMessage(role: .user, content: toolResultBlocks)) - session.appendTranscriptEntry( - .toolCalls(Transcript.ToolCalls(invocations.map(\.call))) - ) - appendedToolResults = true } } @@ -1087,6 +1087,8 @@ extension Transcript { ) ) } + + print("Tool use block \(toolUseBlocks)") messages.append( .init( role: .assistant, @@ -1095,6 +1097,7 @@ extension Transcript { ) case .toolOutput(let toolOutput): // Add user message with tool result + print("tool output \(toolOutput.id)") messages.append( .init( role: .user, From 2f4d42b11868780e1bff5b7c78f8adbb88ee9c0e Mon Sep 17 00:00:00 2001 From: Taylor Lineman Date: Mon, 6 Jul 2026 13:33:41 -0400 Subject: [PATCH 09/11] Keep the ID of streaming text segments the same. Signed-off-by: Taylor Lineman --- Sources/AnyLanguageModel/Transcript.swift | 32 ++++++++++++++++------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/Sources/AnyLanguageModel/Transcript.swift b/Sources/AnyLanguageModel/Transcript.swift index 6b9bce2..d85f327 100644 --- a/Sources/AnyLanguageModel/Transcript.swift +++ b/Sources/AnyLanguageModel/Transcript.swift @@ -31,18 +31,20 @@ public struct Transcript: Sendable, Equatable, Codable { /// /// - Parameter text: The text to update the transcript with. mutating func appendStreamingResponse(_ text: String) { - let textSegment = Transcript.Segment.text(Transcript.TextSegment(content: text)) // Make sure the last entry in the transcript is a response. If it is not, create a new response and append it to the end of the transcript. guard case .response(var response) = entries.last else { - append(Entry.response(Response(assetIDs: [], segments: [textSegment]))) + append(Entry.response(Response(assetIDs: [], segments: [ + Transcript.Segment.text(Transcript.TextSegment(content: text)) + ]))) return } // If the last segment in the last response is text, replace it with the new content. if case .text(let last)? = response.segments.last { - response.segments[response.segments.count - 1] = textSegment + // Keep the same ID as the last segment. + response.segments[response.segments.count - 1] = Transcript.Segment.text(Transcript.TextSegment(id: last.id, content: text)) } else { - response.segments.append(textSegment) + response.segments.append(Transcript.Segment.text(Transcript.TextSegment(content: text))) } // Replace the latest entry with the one we just updated. @@ -56,15 +58,27 @@ public struct Transcript: Sendable, Equatable, Codable { /// - text: The text to replace the final response with. /// - assetIDs: The assetIDs for the response. mutating func finalizeStreamedTranscript(_ text: String, assetIDs: [String]) { - let textSegment = Transcript.Segment.text(Transcript.TextSegment(content: text)) // Make sure the last entry in the transcript is a response. If it is not, create a new response and append it to the end of the transcript. guard case .response(var response) = entries.last else { - append(Entry.response(Response(assetIDs: assetIDs, segments: [textSegment]))) + append(Entry.response(Response(assetIDs: assetIDs, segments: [ + Transcript.Segment.text(Transcript.TextSegment(content: text)) + ]))) return } - - // Replace the latest entry with the one we just updated. - replace(index: entries.count - 1, with: .response(Response(id: response.id, assetIDs: assetIDs, segments: [textSegment]))) + + // If the last segment is text we want to carry its ID over to the new text segment. Otherwise generate a new ID for it. + let id = switch response.segments.last { + case .text(let last): + last.id + default: + UUID().uuidString + } + + let newResponse: Entry = Entry.response(Response(id: response.id, assetIDs: assetIDs, segments: [ + Transcript.Segment.text(Transcript.TextSegment(id: id, content: text)) + ])) + + replace(index: entries.count - 1, with: newResponse) } From b862e0c8abdbc5c1f41cd654b3a8aff1e2b9292c Mon Sep 17 00:00:00 2001 From: Taylor Lineman Date: Thu, 9 Jul 2026 10:41:48 -0400 Subject: [PATCH 10/11] Fix tests & some more comments & warnings Signed-off-by: Taylor Lineman --- .../Models/AnthropicLanguageModel.swift | 11 ++++++----- Sources/AnyLanguageModel/Transcript.swift | 2 +- .../CustomGenerationOptionsTests.swift | 12 ++++++++---- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift b/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift index ceba133..1958b3d 100644 --- a/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift @@ -230,6 +230,7 @@ public struct AnthropicLanguageModel: LanguageModel { case adaptive } + /// How thinking should be returned during generation. public enum ThinkingDisplay: String, Hashable, Codable, Sendable { /// Thinking will be summarized. case summarized @@ -240,13 +241,15 @@ public struct AnthropicLanguageModel: LanguageModel { enum CodingKeys: String, CodingKey { case type case budgetTokens = "budget_tokens" + case display } /// Creates a thinking configuration. /// /// - Parameters: /// - type: The type of thinking to perform. - /// - budgetTokens: The maximum number of tokens to use for thinking. + /// - budgetTokens: The maximum number of tokens to use for thinking. Only required when `type` == `.enabled`. + /// - display: The display type for thoughts. public init(type: ThinkingType, budgetTokens: Int?, display: ThinkingDisplay?) { self.type = type self.budgetTokens = budgetTokens @@ -385,7 +388,6 @@ public struct AnthropicLanguageModel: LanguageModel { var entries: [Transcript.Entry] = [] var runningText = "" - var lastOutput: [AnthropicContent]? var messages: [AnthropicMessage] = session.transcript.toAnthropicMessages() // Loop until no more tool calls are found. @@ -416,7 +418,6 @@ public struct AnthropicLanguageModel: LanguageModel { if case .toolUse(let u) = content { return u } return nil } - lastOutput = message.content if !toolUses.isEmpty { let resolution = try await resolveToolUses(toolUses, session: session) @@ -526,7 +527,7 @@ public struct AnthropicLanguageModel: LanguageModel { var accumulatedText: String = "" var stopReason: String? = nil - var params = try createMessageParams( + let params = try createMessageParams( model: model, system: nil, messages: messages, @@ -627,7 +628,7 @@ public struct AnthropicLanguageModel: LanguageModel { case .messageStop: // Need to use a label, otherwise this would break out of the switch. break eventStream - case .ping, .ignored, .messageStart, .messageStop, .contentBlockStop: + case .ping, .ignored, .messageStart, .contentBlockStop: continue } } diff --git a/Sources/AnyLanguageModel/Transcript.swift b/Sources/AnyLanguageModel/Transcript.swift index d85f327..9716623 100644 --- a/Sources/AnyLanguageModel/Transcript.swift +++ b/Sources/AnyLanguageModel/Transcript.swift @@ -59,7 +59,7 @@ public struct Transcript: Sendable, Equatable, Codable { /// - assetIDs: The assetIDs for the response. mutating func finalizeStreamedTranscript(_ text: String, assetIDs: [String]) { // Make sure the last entry in the transcript is a response. If it is not, create a new response and append it to the end of the transcript. - guard case .response(var response) = entries.last else { + guard case .response(let response) = entries.last else { append(Entry.response(Response(assetIDs: assetIDs, segments: [ Transcript.Segment.text(Transcript.TextSegment(content: text)) ]))) diff --git a/Tests/AnyLanguageModelTests/CustomGenerationOptionsTests.swift b/Tests/AnyLanguageModelTests/CustomGenerationOptionsTests.swift index 3a4a812..b0f5366 100644 --- a/Tests/AnyLanguageModelTests/CustomGenerationOptionsTests.swift +++ b/Tests/AnyLanguageModelTests/CustomGenerationOptionsTests.swift @@ -152,7 +152,7 @@ struct AnthropicCustomOptionsTests { stopSequences: ["END", "STOP"], metadata: .init(userID: "user-123"), toolChoice: .auto, - thinking: .init(budgetTokens: 1024), + thinking: .init(type: .enabled, budgetTokens: 1024, display: .summarized), serviceTier: .priority, extraBody: ["custom_param": .string("value")] ) @@ -187,7 +187,7 @@ struct AnthropicCustomOptionsTests { stopSequences: ["END"], metadata: .init(userID: "user-123"), toolChoice: .tool(name: "my_tool"), - thinking: .init(budgetTokens: 2048), + thinking: .init(type: .enabled, budgetTokens: 2048, display: .summarized), serviceTier: .standard ) @@ -218,7 +218,7 @@ struct AnthropicCustomOptionsTests { topP: 0.9, topK: 40, stopSequences: ["END"], - thinking: .init(budgetTokens: 4096) + thinking: .init(type: .enabled, budgetTokens: 4096, display: .omitted) ) let retrieved = options[custom: AnthropicLanguageModel.self] @@ -226,6 +226,8 @@ struct AnthropicCustomOptionsTests { #expect(retrieved?.topK == 40) #expect(retrieved?.stopSequences == ["END"]) #expect(retrieved?.thinking?.budgetTokens == 4096) + #expect(retrieved?.thinking?.type == .enabled) + #expect(retrieved?.thinking?.display == .omitted) } @Test func metadataCodable() throws { @@ -292,7 +294,7 @@ struct AnthropicCustomOptionsTests { } @Test func thinkingCodable() throws { - let thinking = AnthropicLanguageModel.CustomGenerationOptions.Thinking(budgetTokens: 8192) + let thinking = AnthropicLanguageModel.CustomGenerationOptions.Thinking(type: .enabled, budgetTokens: 8192, display: .omitted) let encoder = JSONEncoder() let data = try encoder.encode(thinking) @@ -302,6 +304,8 @@ struct AnthropicCustomOptionsTests { #expect(json.contains("budget_tokens")) #expect(json.contains("8192")) #expect(json.contains("enabled")) + #expect(json.contains("display")) + #expect(json.contains("omitted")) let decoded = try JSONDecoder().decode( AnthropicLanguageModel.CustomGenerationOptions.Thinking.self, From 57d600940e2fd0ae3042b2fd4fcc2f87a2a6e2ac Mon Sep 17 00:00:00 2001 From: Taylor Lineman Date: Thu, 9 Jul 2026 11:03:50 -0400 Subject: [PATCH 11/11] Added unit tests for anthropic transcript streaming Signed-off-by: Taylor Lineman --- .../AnthropicLanguageModelTests.swift | 77 ++++++++++++++++++- 1 file changed, 74 insertions(+), 3 deletions(-) diff --git a/Tests/AnyLanguageModelTests/AnthropicLanguageModelTests.swift b/Tests/AnyLanguageModelTests/AnthropicLanguageModelTests.swift index 705418e..b0fd1bd 100644 --- a/Tests/AnyLanguageModelTests/AnthropicLanguageModelTests.swift +++ b/Tests/AnyLanguageModelTests/AnthropicLanguageModelTests.swift @@ -11,7 +11,7 @@ private struct AnthropicStructuredForecast { var temperatureCelsius: Int } -@Suite("AnthropicLanguageModel", .enabled(if: anthropicAPIKey?.isEmpty == false)) +@Suite("AnthropicLanguageModel", .serialized, .enabled(if: anthropicAPIKey?.isEmpty == false)) struct AnthropicLanguageModelTests { let model = AnthropicLanguageModel( apiKey: anthropicAPIKey!, @@ -84,6 +84,39 @@ struct AnthropicLanguageModelTests { #expect(!snapshots.last!.rawContent.jsonString.isEmpty) #expect(!(snapshots.last!.content.summary ?? "").isEmpty) } + + @Test func streamingTranscript() async throws { + let session = LanguageModelSession(model: model) + + let stream = session.streamResponse(to: "Say 'Hello' slowly") + + var snapshots: [LanguageModelSession.ResponseStream.Snapshot] = [] + for try await snapshot in stream { + snapshots.append(snapshot) + + // Make sure the snapshot is also in the transcript. + let hasTranscriptEntry = session.transcript.contains(where: { entry in + switch entry { + case .response(let response): + return response.segments.contains { segment in + switch segment { + case .text(let text): + return text.content.contains(snapshot.content) + default: + return false + } + } + default: + return false + } + }) + #expect(hasTranscriptEntry, "Expected the string snapshot to also appear in the transcript during streaming.") + } + + #expect(!snapshots.isEmpty) + #expect(!snapshots.last!.rawContent.jsonString.isEmpty) + } + @Test func withGenerationOptions() async throws { let session = LanguageModelSession(model: model) @@ -125,9 +158,9 @@ struct AnthropicLanguageModelTests { @Test func withTools() async throws { let weatherTool = WeatherTool() let session = LanguageModelSession(model: model, tools: [weatherTool]) - + let response = try await session.respond(to: "How's the weather in San Francisco?") - + var foundToolOutput = false for case let .toolOutput(toolOutput) in response.transcriptEntries { #expect(!toolOutput.id.isEmpty) @@ -137,6 +170,44 @@ struct AnthropicLanguageModelTests { #expect(foundToolOutput) } + + @Test func streamWithTools() async throws { + let weatherTool = WeatherTool() + let session = LanguageModelSession(model: model, tools: [weatherTool]) + + let stream = session.streamResponse(to: "How's the weather in San Francisco?") + + var snapshots: [LanguageModelSession.ResponseStream.Snapshot] = [] + + var toolAppearedInTranscript: Bool = false + var toolResponseAppearedInTranscript: Bool = false + + for try await snapshot in stream { + snapshots.append(snapshot) + + for entry in session.transcript { + switch entry { + case .toolCalls: + toolAppearedInTranscript = true + case .toolOutput: + toolResponseAppearedInTranscript = true + default: break + } + } + } + + #expect(toolAppearedInTranscript, "Expected a tool call to appear in the transcript during streaming.") + #expect(toolResponseAppearedInTranscript, "Expected a tool output to appear in the transcript during streaming.") + + var foundToolOutput = false + for case let .toolOutput(toolOutput) in session.transcript { + #expect(!toolOutput.id.isEmpty) + #expect(toolOutput.toolName == "getWeather") + foundToolOutput = true + } + #expect(foundToolOutput, "Expected the 'getWeather' tool to exist in the final transcript.") + } + @Test func multimodalWithImageURL() async throws { let session = LanguageModelSession(model: model) let response = try await session.respond(