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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 17 additions & 19 deletions Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -580,28 +580,26 @@ public struct AnthropicLanguageModel: LanguageModel, ProfileRequestIdentifiedLan
for invocation in invocations {
entries.append(.toolOutput(invocation.output))
}
if requestContext.hasDynamicProfile {
inFlightMessages.append(.init(role: .assistant, content: message.content))
let results: [AnthropicContent] = try invocations.map { invocation in
.toolResult(
.init(
toolUseId: invocation.call.id,
content: try convertSegmentsToAnthropicContent(
invocation.output.segments,
isToolOutput: true
)
inFlightMessages.append(.init(role: .assistant, content: message.content))
let results: [AnthropicContent] = try invocations.map { invocation in
.toolResult(
.init(
toolUseId: invocation.call.id,
content: try convertSegmentsToAnthropicContent(
invocation.output.segments,
isToolOutput: true
)
)
}
inFlightMessages.append(.init(role: .user, content: results))
if type == String.self {
earlierText += message.content.compactMap { block -> String? in
if case .text(let text) = block { return text.text }
return nil
}.joined()
}
continue
)
}
inFlightMessages.append(.init(role: .user, content: results))
if type == String.self {
earlierText += message.content.compactMap { block -> String? in
if case .text(let text) = block { return text.text }
return nil
}.joined()
}
continue
}
}

Expand Down
13 changes: 1 addition & 12 deletions Tests/AnyLanguageModelTests/AnthropicProfileTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,18 +91,7 @@ import Testing

@Test(arguments: [Provider.anthropic], [false, true])
func anthropicOrdinaryToolLoop(_ provider: Provider, _ streaming: Bool) async throws {
if streaming {
try await ordinaryWrapperKeepsTheProviderToolLoop(provider, streaming)
} else {
UsageURLProtocol.reset()
UsageURLProtocol.enqueue(json: try Self.json(provider.response(text: "", tool: true)))
let session = provider.makeSession(tools: [WeatherTool()])
let response = try await session.respond(to: "Weather?")
#expect(response.content.isEmpty)
#expect(UsageURLProtocol.recordedBodies.count == 1)
#expect(response.transcriptEntries.count == 2)
#expect(session.transcript.count == 4)
}
try await ordinaryWrapperKeepsTheProviderToolLoop(provider, streaming)
}
}
#endif
24 changes: 7 additions & 17 deletions Tests/AnyLanguageModelTests/AnthropicReasoningTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,26 +65,16 @@ import Testing
result = try await session.streamResponse(to: "Weather").collect()
} else {
ReasoningURLProtocol.enqueue(json: response(tool: true))
ReasoningURLProtocol.enqueue(json: response())
result = try await session.respond(to: "Weather")
}
#expect(result.content == (streaming ? "Answer" : ""))
#expect(result.transcriptEntries.count == (streaming ? 4 : 3))
#expect(result.content == "Answer")
#expect(result.transcriptEntries.count == 4)
#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"))
}
try #require(ReasoningURLProtocol.recordedBodies.count == 2)
let body = String(decoding: ReasoningURLProtocol.recordedBodies[1], as: UTF8.self)
#expect(body.contains("opaque-signature"))
#expect(body.contains("tool_result"))
}

enum LimitedToolFlow: CaseIterable {
Expand Down
57 changes: 57 additions & 0 deletions Tests/AnyLanguageModelTests/AnthropicToolContinuationTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import Foundation
import Testing

@testable import AnyLanguageModel

#if canImport(Darwin) && !canImport(AsyncHTTPClient)
extension ProviderUsageTests {
@Test func anthropicRespondContinuesWithToolOutputForText() async throws {
let session = try anthropicToolThenAnswerSession(answer: "Sunny")
let response = try await session.respond(to: "Weather?")
#expect(response.content == "Sunny")
#expect(response.transcriptEntries.count == 2)
try expectAnthropicContinuation(of: session)
}

@Test func anthropicRespondContinuesWithToolOutputForStructuredContent() async throws {
let session = try anthropicToolThenAnswerSession(answer: #"{"answer":"Sunny"}"#)
let response = try await session.respond(to: "Weather?", generating: Answer.self)
#expect(response.content.answer == "Sunny")
#expect(response.transcriptEntries.count == 2)
try expectAnthropicContinuation(of: session)
}

private func anthropicToolThenAnswerSession(answer: String) throws -> LanguageModelSession {
let provider = Provider.anthropic
UsageURLProtocol.reset()
UsageURLProtocol.enqueue(
json: try Self.json(provider.response(text: "", counts: provider.counts, tool: true))
)
UsageURLProtocol.enqueue(json: try Self.json(provider.response(text: answer, counts: provider.counts)))
return provider.makeSession(tools: [WeatherTool()])
}

private func expectAnthropicContinuation(of session: LanguageModelSession) throws {
let bodies = UsageURLProtocol.recordedBodies
try #require(bodies.count == 2)

let body = try #require(try JSONSerialization.jsonObject(with: bodies[1]) as? [String: Any])
let messages = try #require(body["messages"] as? [[String: Any]])
let call = try #require((messages[messages.count - 2]["content"] as? [[String: Any]])?.first)
#expect(messages[messages.count - 2]["role"] as? String == "assistant")
#expect(call["type"] as? String == "tool_use")
#expect(call["id"] as? String == "call_1")

let result = try #require((messages[messages.count - 1]["content"] as? [[String: Any]])?.first)
let output = try #require((result["content"] as? [[String: Any]])?.first)
#expect(messages[messages.count - 1]["role"] as? String == "user")
#expect(result["type"] as? String == "tool_result")
#expect(result["tool_use_id"] as? String == "call_1")
#expect(output["text"] as? String == "The weather in Paris is sunny and 72°F / 23°C")

var everyRequest = Provider.anthropic.expected
everyRequest.add(Provider.anthropic.expected)
#expect(session.usage == everyRequest)
}
}
#endif
24 changes: 9 additions & 15 deletions Tests/AnyLanguageModelTests/ProviderUsageTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -505,7 +505,7 @@ import Testing
#expect(response.content.answer == "Hello")
}

@Test(arguments: [Provider.chat, .responses, .openResponses, .gemini])
@Test(arguments: [Provider.chat, .responses, .openResponses, .anthropic, .gemini])
func toolRoundsKeepEarlierText(_ provider: Provider) async throws {
UsageURLProtocol.reset()
UsageURLProtocol.enqueue(json: try Self.json(provider.response(text: "Checking. ", tool: true)))
Expand Down Expand Up @@ -548,9 +548,7 @@ import Testing
UsageURLProtocol.enqueue(
json: try streaming ? provider.toolStream() : Self.json(provider.response(text: "", tool: true))
)
if streaming || provider == .gemini {
UsageURLProtocol.enqueue(json: try streaming ? provider.stream() : Self.json(provider.response()))
}
UsageURLProtocol.enqueue(json: try streaming ? provider.stream() : Self.json(provider.response()))
UsageURLProtocol.enqueue(json: try Self.json(provider.response()))
let url = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
Expand All @@ -570,7 +568,7 @@ import Testing
let restored = provider.makeSession(transcript: try JSONDecoder().decode(Transcript.self, from: archived))
_ = try await restored.respond(to: "Review the image")

#expect(UsageURLProtocol.recordedBodies.count == (streaming || provider == .gemini ? 3 : 2))
#expect(UsageURLProtocol.recordedBodies.count == 3)
for body in UsageURLProtocol.recordedBodies.dropFirst() {
let request = try JSONDecoder().decode([String: JSONValue].self, from: body)
if provider == .gemini {
Expand Down Expand Up @@ -672,9 +670,7 @@ import Testing
UsageURLProtocol.enqueue(
json: try streaming ? provider.toolStream() : Self.json(provider.response(text: "", tool: true))
)
if streaming || provider == .gemini {
UsageURLProtocol.enqueue(json: try streaming ? provider.stream() : Self.json(provider.response()))
}
UsageURLProtocol.enqueue(json: try streaming ? provider.stream() : Self.json(provider.response()))
if streaming {
_ = try await session.streamResponse(to: "Weather?").collect()
} else {
Expand All @@ -690,7 +686,7 @@ import Testing
} else {
_ = try await restored.respond(to: "Review the image")
}
#expect(UsageURLProtocol.recordedBodies.count == (streaming || provider == .gemini ? 3 : 2))
#expect(UsageURLProtocol.recordedBodies.count == 3)
for body in UsageURLProtocol.recordedBodies.dropFirst() {
try expectToolImage(in: body, provider: provider, texts: expectedTexts, mimeType: expectedMIME)
}
Expand Down Expand Up @@ -1028,8 +1024,6 @@ import Testing
_ provider: Provider,
_ streaming: Bool
) async throws {
// Without a dynamic profile, Anthropic's `respond` makes one request.
if provider == .anthropic && !streaming { return }
func expectStopped<Content: Generable>(_ type: Content.Type) async throws {
UsageURLProtocol.reset()
for _ in 0 ..< 2 {
Expand Down Expand Up @@ -1423,7 +1417,7 @@ import Testing
#expect(!session.isResponding)
}

@Test(arguments: [Provider.chat, .responses, .openResponses, .gemini])
@Test(arguments: [Provider.chat, .responses, .openResponses, .anthropic, .gemini])
func toolRoundsStopAtLimit(_ provider: Provider) async throws {
UsageURLProtocol.reset()
var options = GenerationOptions()
Expand Down Expand Up @@ -1487,7 +1481,7 @@ import Testing
#expect(UsageURLProtocol.recordedBodies.count == 3)
}

@Test(arguments: [Provider.chat, .responses, .openResponses, .gemini])
@Test(arguments: [Provider.chat, .responses, .openResponses, .anthropic, .gemini])
func repeatedToolRoundRuns(_ provider: Provider) async throws {
UsageURLProtocol.reset()
UsageURLProtocol.enqueue(json: try Self.json(provider.response(text: "", tool: true)))
Expand All @@ -1513,7 +1507,7 @@ import Testing

/// As in Foundation Models, a response reports its final request's usage,
/// and the session counts every request.
@Test(arguments: [Provider.chat, .responses, .openResponses, .gemini])
@Test(arguments: [Provider.chat, .responses, .openResponses, .anthropic, .gemini])
func toolRoundsReportTheFinalRequest(_ provider: Provider) async throws {
UsageURLProtocol.reset()
UsageURLProtocol.enqueue(
Expand All @@ -1532,7 +1526,7 @@ import Testing
}

/// The final request's usage is its own, even when it reports none and an earlier one did.
@Test(arguments: [Provider.chat, .responses, .openResponses, .gemini])
@Test(arguments: [Provider.chat, .responses, .openResponses, .anthropic, .gemini])
func finalRequestWithoutUsageReportsNone(_ provider: Provider) async throws {
UsageURLProtocol.reset()
UsageURLProtocol.enqueue(
Expand Down
18 changes: 5 additions & 13 deletions Tests/AnyLanguageModelTests/Shared/ProfileProviderFixtures.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,11 @@ import Testing
profile: Bool
) async throws {
UsageURLProtocol.reset()
let legacyAnthropic = provider == .anthropic && !streaming && !profile
let roundsPerTurn = legacyAnthropic ? 1 : 2
let observedCalls = Locked<[Transcript.Entry]>([])
var expectedOutputs: [String] = []
var expectedRequests: [[String]] = []
for turn in 0 ..< 2 {
for round in 0 ..< roundsPerTurn {
for round in 0 ..< 2 {
expectedRequests.append(expectedOutputs)
let city = "Turn\(turn)Round\(round)"
var response =
Expand All @@ -35,16 +33,10 @@ import Testing
UsageURLProtocol.enqueue(json: response)
expectedOutputs.append("OUTPUT:\(city)")
}
if !legacyAnthropic {
expectedRequests.append(expectedOutputs)
UsageURLProtocol.enqueue(
json: try streaming ? provider.stream() : Self.json(provider.response())
)
}
}
if legacyAnthropic {
expectedRequests.append(expectedOutputs)
UsageURLProtocol.enqueue(json: try Self.json(provider.response()))
UsageURLProtocol.enqueue(
json: try streaming ? provider.stream() : Self.json(provider.response())
)
}
let session: LanguageModelSession
if profile {
Expand All @@ -63,7 +55,7 @@ import Testing
} else {
session = provider.makeSession(tools: [ReusedCallIDTool(observedCalls: observedCalls)])
}
for _ in 0 ..< (legacyAnthropic ? 3 : 2) {
for _ in 0 ..< 2 {
if streaming {
_ = try await session.streamResponse(to: "Weather?").collect()
} else {
Expand Down
2 changes: 1 addition & 1 deletion Tests/AnyLanguageModelTests/ToolCallingModeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ struct ToolsOfferedTests {
#expect(body["toolConfig"] == nil)
}

@Test(arguments: [Provider.chat, .responses, .openResponses, .gemini])
@Test(arguments: [Provider.chat, .responses, .openResponses, .anthropic, .gemini])
func followUpRequestKeepsRequiredMode(_ provider: Provider) async throws {
ToolCallingModeURLProtocol.reset()
ToolCallingModeURLProtocol.enqueue(provider.response(tool: true))
Expand Down
Loading