diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 8453d2e..e4ef9a6 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -12,7 +12,7 @@ on: jobs: build: - runs-on: macos-14 + runs-on: macos-15 steps: - uses: actions/checkout@v4 diff --git a/.swiftpm/configuration/Package.resolved b/.swiftpm/configuration/Package.resolved deleted file mode 100644 index fd5eda2..0000000 --- a/.swiftpm/configuration/Package.resolved +++ /dev/null @@ -1,86 +0,0 @@ -{ - "pins" : [ - { - "identity" : "aws-crt-swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/awslabs/aws-crt-swift", - "state" : { - "revision" : "0d0a0cf2e2cb780ceeceac190b4ede94f4f96902", - "version" : "0.26.0" - } - }, - { - "identity" : "aws-sdk-swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/awslabs/aws-sdk-swift", - "state" : { - "revision" : "7b0d55676eb896e472662922e80ebe30ae3ca8ed", - "version" : "0.39.0" - } - }, - { - "identity" : "documentationcomment", - "kind" : "remoteSourceControl", - "location" : "https://github.com/fumito-ito/DocumentationComment.git", - "state" : { - "revision" : "90d3c5b9cccddb202c8adb99138733c868d7b082", - "version" : "0.0.6" - } - }, - { - "identity" : "grmustache.swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/groue/GRMustache.swift", - "state" : { - "revision" : "6e9dcfb807959e19f2915be1928aeccf01babba5", - "version" : "4.2.0" - } - }, - { - "identity" : "smithy-swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/smithy-lang/smithy-swift", - "state" : { - "revision" : "0267f0c649558cbc1323bbc5a0c8b2403239655b", - "version" : "0.44.0" - } - }, - { - "identity" : "swift-cmark", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-cmark.git", - "state" : { - "revision" : "3bc2f3e25df0cecc5dc269f7ccae65d0f386f06a", - "version" : "0.4.0" - } - }, - { - "identity" : "swift-log", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-log.git", - "state" : { - "revision" : "e97a6fcb1ab07462881ac165fdbb37f067e205d5", - "version" : "1.5.4" - } - }, - { - "identity" : "swift-markdown", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-markdown.git", - "state" : { - "revision" : "4aae40bf6fff5286e0e1672329d17824ce16e081", - "version" : "0.4.0" - } - }, - { - "identity" : "swift-syntax", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-syntax.git", - "state" : { - "revision" : "64889f0c732f210a935a0ad7cda38f77f876262d", - "version" : "509.1.1" - } - } - ], - "version" : 2 -} diff --git a/Example.swiftpm/ContentView.swift b/Example.swiftpm/ContentView.swift index 418a9b7..70aaf6d 100644 --- a/Example.swiftpm/ContentView.swift +++ b/Example.swiftpm/ContentView.swift @@ -1,26 +1,13 @@ import SwiftUI -import AnthropicSwiftSDK_VertexAI import AnthropicSwiftSDK -import AWSBedrockRuntime -import AnthropicSwiftSDK_Bedrock struct ContentView: View { // MARK: Properties for Claude @State private var claudeAPIKey = "" - @State private var isStreamClaude: Bool = false - - // MARK: Properties for Bedrock - @State private var bedrockRegion = "" - @State private var isStreamBedrock: Bool = false - - // MARK: Properties for Vertex - @State private var vertexProjectID = "" - @State private var vertexAuthToken = "" - @State private var isStreamVertex: Bool = false var body: some View { TabView { - // MARK: Claude + // MARK: Claude Messages API NavigationStack { VStack { Spacer() @@ -29,20 +16,10 @@ struct ContentView: View { .padding() .textFieldStyle(.roundedBorder) - Toggle(isOn: $isStreamClaude) { - Text("Enable Stream API") - } - .padding() - NavigationLink { let claude = Anthropic(apiKey: claudeAPIKey) - if isStreamClaude { - let observable = StreamViewModel(messageHandler: claude.messages, title: "Stream \\w Claude") - StreamView(observable: observable) - } else { - let observable = SendViewModel(messageHandler: claude.messages, title: "Message \\w Claude") - SendView(observable: observable) - } + let observable = SendViewModel(messageHandler: claude.messages, title: "Message \\w Claude") + SendView(observable: observable) } label: { Text("Continue") .frame(maxWidth: .infinity, minHeight: 48) @@ -59,37 +36,26 @@ struct ContentView: View { Spacer() } - .navigationTitle("Claude Demo") + .navigationTitle("Claude Send Message Demo") } .tabItem { Image(systemName: "pencil.and.scribble") - Text("Claude") + Text("Send Message") } - - // MARK: Bedrock + + // MARK: Claude Stream Messages API NavigationStack { VStack { Spacer() - TextField("Enter Region Code", text: $bedrockRegion) + TextField("Enter API Key", text: $claudeAPIKey) .padding() .textFieldStyle(.roundedBorder) - Toggle(isOn: $isStreamBedrock) { - Text("Enable Stream API") - } - .padding() - NavigationLink { - let bedrockClient = try! BedrockRuntimeClient(region: bedrockRegion) - let claude = bedrockClient.useAnthropic() - if isStreamBedrock { - let observable = StreamViewModel(messageHandler: claude.messages, title: "Stream \\w Bedrock", model: .claude_3_Opus) - StreamView(observable: observable) - } else { - let observable = SendViewModel(messageHandler: claude.messages, title: "Message \\w Bedrock", model: .claude_3_Opus) - SendView(observable: observable) - } + let claude = Anthropic(apiKey: claudeAPIKey) + let observable = StreamViewModel(messageHandler: claude.messages, title: "Stream \\w Claude") + StreamView(observable: observable) } label: { Text("Continue") .frame(maxWidth: .infinity, minHeight: 48) @@ -97,49 +63,35 @@ struct ContentView: View { .background( Capsule() .foregroundColor( - bedrockRegion.isEmpty ? .gray.opacity(0.2) : .blue + claudeAPIKey.isEmpty ? .gray.opacity(0.2) : .blue ) ) } .padding() - .disabled(bedrockRegion.isEmpty) + .disabled(claudeAPIKey.isEmpty) Spacer() } - .navigationTitle("Bedrock Demo") + .navigationTitle("Claude Stream Message Demo") } .tabItem { - Image(systemName: "globe.americas.fill") - Text("Bedrock") + Image(systemName: "pencil.and.scribble") + Text("Stream Message") } - // MARK: Vertex + // MARK: Claude Send Message Batches API NavigationStack { VStack { Spacer() - TextField("Enter Project ID", text: $vertexProjectID) - .padding() - .textFieldStyle(.roundedBorder) - - TextField("Enter Auth Token", text: $vertexAuthToken) + TextField("Enter API Key", text: $claudeAPIKey) .padding() .textFieldStyle(.roundedBorder) - Toggle(isOn: $isStreamVertex) { - Text("Enable Stream API") - } - .padding() - NavigationLink { - let claude = AnthropicVertexAIClient(projectId: vertexProjectID, accessToken: vertexAuthToken, region: .europeWest1) - if isStreamVertex { - let observable = StreamViewModel(messageHandler: claude.messages, title: "Stream \\w Vertex") - StreamView(observable: observable) - } else { - let observable = SendViewModel(messageHandler: claude.messages, title: "Message \\w Vertex") - SendView(observable: observable) - } + let claude = Anthropic(apiKey: claudeAPIKey) + let observable = SendMessageBatchesViewModel(messageHandler: claude.messageBatches, title: "Batch \\w Claude") + SendMessageBatchView(observable: observable) } label: { Text("Continue") .frame(maxWidth: .infinity, minHeight: 48) @@ -147,20 +99,20 @@ struct ContentView: View { .background( Capsule() .foregroundColor( - vertexProjectID.isEmpty || vertexAuthToken.isEmpty ? .gray.opacity(0.2) : .blue + claudeAPIKey.isEmpty ? .gray.opacity(0.2) : .blue ) ) } .padding() - .disabled(vertexProjectID.isEmpty || vertexAuthToken.isEmpty) + .disabled(claudeAPIKey.isEmpty) Spacer() } - .navigationTitle("VertexAI Demo") + .navigationTitle("Claude Batch Message Demo") } .tabItem { - Image(systemName: "mountain.2.fill") - Text("Vertex") + Image(systemName: "pencil.and.scribble") + Text("Batch Message") } } diff --git a/Example.swiftpm/Extension/Messages+Extension.swift b/Example.swiftpm/Extension/Messages+Extension.swift index 6f19ada..736778b 100644 --- a/Example.swiftpm/Extension/Messages+Extension.swift +++ b/Example.swiftpm/Extension/Messages+Extension.swift @@ -7,12 +7,7 @@ import Foundation import AnthropicSwiftSDK -import AnthropicSwiftSDK_Bedrock -import AnthropicSwiftSDK_VertexAI extension AnthropicSwiftSDK.Messages: MessageSendable {} extension AnthropicSwiftSDK.Messages: MessageStreamable {} -extension AnthropicSwiftSDK_Bedrock.Messages: MessageSendable {} -extension AnthropicSwiftSDK_Bedrock.Messages: MessageStreamable {} -extension AnthropicSwiftSDK_VertexAI.Messages: MessageSendable {} -extension AnthropicSwiftSDK_VertexAI.Messages: MessageStreamable {} +extension AnthropicSwiftSDK.MessageBatches: MessageBatchSendable {} diff --git a/Example.swiftpm/Package.resolved b/Example.swiftpm/Package.resolved index b081541..e904133 100644 --- a/Example.swiftpm/Package.resolved +++ b/Example.swiftpm/Package.resolved @@ -1,23 +1,5 @@ { "pins" : [ - { - "identity" : "aws-crt-swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/awslabs/aws-crt-swift", - "state" : { - "revision" : "7b42e0343f28b3451aab20840dc670abd12790bd", - "version" : "0.36.0" - } - }, - { - "identity" : "aws-sdk-swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/awslabs/aws-sdk-swift", - "state" : { - "revision" : "192bc5984cb765a88f8a59d0678aeb85a888a9f5", - "version" : "1.0.8" - } - }, { "identity" : "documentationcomment", "kind" : "remoteSourceControl", @@ -45,15 +27,6 @@ "version" : "5.0.1" } }, - { - "identity" : "smithy-swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/smithy-lang/smithy-swift", - "state" : { - "revision" : "c5242d4e06e721293ee91c7affd1d527912f596d", - "version" : "0.76.0" - } - }, { "identity" : "swift-cmark", "kind" : "remoteSourceControl", @@ -63,15 +36,6 @@ "version" : "0.4.0" } }, - { - "identity" : "swift-log", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-log.git", - "state" : { - "revision" : "9cb486020ebf03bfa5b5df985387a14a98744537", - "version" : "1.6.1" - } - }, { "identity" : "swift-markdown", "kind" : "remoteSourceControl", @@ -89,6 +53,15 @@ "revision" : "0687f71944021d616d34d922343dcef086855920", "version" : "600.0.1" } + }, + { + "identity" : "swiftyjsonlines", + "kind" : "remoteSourceControl", + "location" : "https://github.com/fumito-ito/SwiftyJSONLines.git", + "state" : { + "revision" : "ae30455420897b2c45621a88b3ae3f052a8b5f3d", + "version" : "0.0.2" + } } ], "version" : 2 diff --git a/Example.swiftpm/Package.swift b/Example.swiftpm/Package.swift index 04b8360..36d9b3d 100644 --- a/Example.swiftpm/Package.swift +++ b/Example.swiftpm/Package.swift @@ -17,7 +17,7 @@ let package = Package( name: "Example", targets: ["AppModule"], bundleIdentifier: "com.github.fumito-ito.AnthropicSwiftSDK.Example", - teamIdentifier: "L8LPZ499U7", + teamIdentifier: "K489QY5CFD", displayVersion: "1.0", bundleVersion: "1", appIcon: .placeholder(icon: .clock), @@ -44,9 +44,7 @@ let package = Package( .executableTarget( name: "AppModule", dependencies: [ - .product(name: "AnthropicSwiftSDK", package: "AnthropicSwiftSDK"), - .product(name: "AnthropicSwiftSDK-Bedrock", package: "AnthropicSwiftSDK"), - .product(name: "AnthropicSwiftSDK-VertexAI", package: "AnthropicSwiftSDK") + .product(name: "AnthropicSwiftSDK", package: "AnthropicSwiftSDK") ], path: "." ) diff --git a/Example.swiftpm/Protocol/MessagesSubject.swift b/Example.swiftpm/Protocol/MessagesSubject.swift index a63414b..e18bfdf 100644 --- a/Example.swiftpm/Protocol/MessagesSubject.swift +++ b/Example.swiftpm/Protocol/MessagesSubject.swift @@ -33,6 +33,12 @@ protocol StreamMessagesSubject: MessagesSubject { func streamMessage(text: String) async throws } +protocol SendMessageBatchesSubject: MessagesSubject { + init(messageHandler: MessageBatchSendable, title: String, model: Model) + + func sendMessageBatch(text: String) async throws +} + protocol MessageSendable { func createMessage( _ messages: [Message], @@ -49,6 +55,12 @@ protocol MessageSendable { ) async throws -> MessagesResponse } +protocol MessageBatchSendable { + func createBatches(batches: [MessageBatch]) async throws -> BatchResponse + + func results(streamOf batchId: String) async throws -> AsyncThrowingStream +} + protocol MessageStreamable { func streamMessage( _ messages: [Message], diff --git a/Example.swiftpm/View/SendBatchView.swift b/Example.swiftpm/View/SendBatchView.swift new file mode 100644 index 0000000..6edf82e --- /dev/null +++ b/Example.swiftpm/View/SendBatchView.swift @@ -0,0 +1,78 @@ +// +// SwiftUIView.swift +// Example +// +// Created by 伊藤史 on 2024/10/25. +// + +import SwiftUI + +struct SendMessageBatchView: View { + @State var observable: SendMessageBatchesSubject + @State private var prompt = "" + + var body: some View { + VStack { + List(observable.messages) { message in + HStack { + if message.user == .assistant { + Image(systemName: "brain.filled.head.profile").frame(alignment: .leading) + Text(message.text).frame(maxWidth: .infinity, alignment: .leading) + + } else { + Text(message.text).frame(maxWidth: .infinity, alignment: .trailing) + Image(systemName: "person.fill").frame(alignment: .trailing) + } + } + } + .padding(.bottom, 1) + .alert("Error", isPresented: $observable.isShowingError) { + Button("OK") {} + } message: { + Text(observable.errorMessage) + } + .overlay( + Group { + if observable.isLoading { + ProgressView() + } else { + EmptyView() + } + } + ) + } + .safeAreaInset(edge: .bottom) { + VStack(spacing: 0) { + textArea + } + } + .navigationTitle(observable.title) + } + + var textArea: some View { + HStack(spacing: 2) { + Button { + observable.clear() + } label: { + Image(systemName: "eraser") + } + .buttonStyle(.bordered) + TextField("Enter prompt", text: $prompt, axis: .vertical) + .textFieldStyle(.roundedBorder) + .padding() + Button { + Task { + do { + try await observable.sendMessageBatch(text: prompt) + } catch { + print(error) + } + } + } label: { + Image(systemName: "paperplane") + } + .buttonStyle(.bordered) + } + .padding() + } +} diff --git a/Example.swiftpm/ViewModel/SendBatchViewModel.swift b/Example.swiftpm/ViewModel/SendBatchViewModel.swift new file mode 100644 index 0000000..0177c14 --- /dev/null +++ b/Example.swiftpm/ViewModel/SendBatchViewModel.swift @@ -0,0 +1,76 @@ +// +// SendBatchViewModel.swift +// Example +// +// Created by 伊藤史 on 2024/10/25. +// + +import Foundation +import AnthropicSwiftSDK + +@Observable class SendMessageBatchesViewModel: SendMessageBatchesSubject { + + private let messageHandler: MessageBatchSendable + private let functionTools = FunctionTools() + let title: String + let model: Model + + var messages: [ChatMessage] = [] + + var errorMessage: String = "" { + didSet { + isShowingError = errorMessage.isEmpty == false + } + } + + var isShowingError: Bool = false + + var isLoading: Bool = false + + private var task: Task? = nil + + required init( + messageHandler: any MessageBatchSendable, + title: String, + model: Model = .claude_3_5_Sonnet) { + self.messageHandler = messageHandler + self.title = title + self.model = model + + } + + func sendMessageBatch(text: String) async throws { + messages.append(.init(user: .user, text: text)) + + let message = Message(role: .user, content: [.text(text)]) + let customId = UUID().uuidString + let batch = MessageBatch(customId: customId, parameter: .init(messages: [message], maxTokens: 1024)) + + task = Task { + do { + isLoading = true + let result = try await messageHandler.createBatches(batches: [batch]) + let stream = try await messageHandler.results(streamOf: result.id) + for try await chunk in stream { + guard case .text(let text) = chunk.result?.message?.content.first else { + return + } + messages.append(.init(user: .assistant, text: text)) + } + isLoading = false + } catch { + errorMessage = "\(error)" + isLoading = false + } + + } + } + + func cancel() { + task?.cancel() + } + + func clear() { + messages = [] + } +} diff --git a/Package.resolved b/Package.resolved index e64f613..aadb139 100644 --- a/Package.resolved +++ b/Package.resolved @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-cmark.git", "state" : { - "revision" : "3bc2f3e25df0cecc5dc269f7ccae65d0f386f06a", - "version" : "0.4.0" + "revision" : "3ccff77b2dc5b96b77db3da0d68d28068593fa53", + "version" : "0.5.0" } }, { @@ -41,8 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-markdown.git", "state" : { - "revision" : "4aae40bf6fff5286e0e1672329d17824ce16e081", - "version" : "0.4.0" + "revision" : "8f79cb175981458a0a27e76cb42fee8e17b1a993", + "version" : "0.5.0" } }, { @@ -50,8 +50,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-syntax.git", "state" : { - "revision" : "cb53fa1bd3219b0b23ded7dfdd3b2baff266fd25", - "version" : "600.0.0" + "revision" : "0687f71944021d616d34d922343dcef086855920", + "version" : "600.0.1" + } + }, + { + "identity" : "swiftyjsonlines", + "kind" : "remoteSourceControl", + "location" : "https://github.com/fumito-ito/SwiftyJSONLines.git", + "state" : { + "revision" : "fb957dada1e920b6916c7083e99895f8f7fc885a", + "version" : "0.0.3" } } ], diff --git a/Package.swift b/Package.swift index dc8403c..811b161 100644 --- a/Package.swift +++ b/Package.swift @@ -7,7 +7,7 @@ let package = Package( name: "AnthropicSwiftSDK", platforms: [ .iOS(.v17), - .macOS(.v13) + .macOS(.v14) ], products: [ // Products define the executables and libraries a package produces, making them visible to other packages. @@ -16,7 +16,8 @@ let package = Package( targets: ["AnthropicSwiftSDK"]), ], dependencies: [ - .package(url: "https://github.com/fumito-ito/FunctionCalling", from: "0.4.0") + .package(url: "https://github.com/fumito-ito/FunctionCalling", from: "0.4.0"), + .package(url: "https://github.com/fumito-ito/SwiftyJSONLines.git", from: "0.0.3") ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. @@ -24,7 +25,8 @@ let package = Package( .target( name: "AnthropicSwiftSDK", dependencies: [ - .product(name: "FunctionCalling", package: "FunctionCalling") + .product(name: "FunctionCalling", package: "FunctionCalling"), + .product(name: "SwiftyJSONLines", package: "SwiftyJSONLines") ] ), .testTarget( diff --git a/README.md b/README.md index d8450fc..19b94dc 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,58 @@ let result = try await Anthropic(apiKey: "your_claude_api_key") ) ``` +### [Prompt Caching (beta)](https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching) + +Prompt caching is a powerful feature that optimizes your API usage by allowing resuming from specific prefixes in your prompts. + +You can cache large text content, e.g. “Pride and Prejudice”, using the cache_control parameter. This enables reuse of this large text across multiple API calls without reprocessing it each time. Changing only the user message allows you to ask various questions about the book while utilizing the cached content, leading to faster responses and improved efficiency. + +Here’s an example of how to implement prompt caching with the Messages API using a cache_control block: + +```swift +let anthropic = Anthropic(apiKey: "YOUR_OWN_API_KEY") + +let message = Message(role: .user, content: [.text("Analyze the major themes in Pride and Prejudice.")]) +let response = try await anthropic.messages.createMessage( + [message], + system: [ + .text("You are an AI assistant tasked with analyzing literary works. Your goal is to provide insightful commentary on themes, characters, and writing style.\n", nil), + .text("", .ephemeral) + ], + maxTokens: 1024 +) +``` + +### [Message Batches (beta)](https://docs.anthropic.com/en/docs/build-with-claude/message-batches) + +The Message Batches API is a powerful, cost-effective way to asynchronously process large volumes of [Messages](https://docs.anthropic.com/en/api/messages) requests. This approach is well-suited to tasks that do not require immediate responses, reducing costs by 50% while increasing throughput. + +This is especially useful for bulk operations that don’t require immediate results. + +Here's an example of how to process many messages with the Message Bathches API: + +```swift +let anthropic = Anthropic(apiKey: "YOUR_OWN_API_KEY") + +let messages = [ + Message(role: .user, content: [.text("Write a haiku about robots.")]), + Message(role: .user, content: [.text("Write a haiku about robots. Skip the preamble; go straight into the poem.")]), + Message(role: .user, content: [.text("Who is the best basketball player of all time?")]), + Message(role: .user, content: [.text("Who is the best basketball player of all time? Yes, there are differing opinions, but if you absolutely had to pick one player, who would it be?")]) + // .... +] + +let batch = MessageBatch( + customId: "my-first-batch-request", + parameter: .init( + messages: messages, + maxTokens: 1024 + ) +) + +let response = try await anthropic.messageBatches.createBatches(batches: [batch]) +``` + ## Extensions By introducing an extension Swift package, it is possible to access the Anthropic Claude API through AWS Bedrock and Vertex AI. The supported services are as follows: diff --git a/Sources/AnthropicSwiftSDK-TestUtils/HTTPMock.swift b/Sources/AnthropicSwiftSDK-TestUtils/HTTPMock.swift index 15b9ab9..6044bfa 100644 --- a/Sources/AnthropicSwiftSDK-TestUtils/HTTPMock.swift +++ b/Sources/AnthropicSwiftSDK-TestUtils/HTTPMock.swift @@ -9,9 +9,10 @@ import Foundation public enum MockInspectType { case none - case request((URLRequest) -> Void) - case requestHeader(([String: String]?) -> Void) + case request((URLRequest) -> Void, String?) + case requestHeader(([String: String]?) -> Void, String?) case response(String) + case error(String) } public class HTTPMock: URLProtocol { @@ -26,43 +27,45 @@ public class HTTPMock: URLProtocol { } public override func startLoading() { - if case let .request(inspection) = Self.inspectType { + if case let .request(inspection, _) = Self.inspectType { inspection(request) } - if case let .requestHeader(inspection) = Self.inspectType { + if case let .requestHeader(inspection, _) = Self.inspectType { inspection(request.allHTTPHeaderFields) } if let url = request.url, - let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/2", headerFields: nil) { - client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + let succeedResponse = HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/2", headerFields: nil), + let errorResponse = HTTPURLResponse(url: url, statusCode: 400, httpVersion: "HTTP/2", headerFields: nil) { - if case let .response(jsonString) = Self.inspectType, - let data = jsonString.data(using: .utf8) { + switch Self.inspectType { + case .none: + client?.urlProtocol(self, didReceive: succeedResponse, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: getBasicJSONStringData()) + case .request(_, nil), .requestHeader(_, nil): + client?.urlProtocol(self, didReceive: succeedResponse, cacheStoragePolicy: .notAllowed) + case .request(_, let jsonString), .requestHeader(_, let jsonString): + client?.urlProtocol(self, didReceive: succeedResponse, cacheStoragePolicy: .notAllowed) + guard let jsonString, let data = jsonString.data(using: .utf8) else { + client?.urlProtocol(self, didLoad: getBasicJSONStringData()) + return + } + client?.urlProtocol(self, didLoad: data) + case .response(let jsonString): + client?.urlProtocol(self, didReceive: succeedResponse, cacheStoragePolicy: .notAllowed) + guard let data = jsonString.data(using: .utf8) else { + client?.urlProtocol(self, didLoad: getBasicJSONStringData()) + return + } client?.urlProtocol(self, didLoad: data) - } else { - let basicResponse = """ - { - "id": "msg_01XFDUDYJgAACzvnptvVoYEL", - "type": "message", - "role": "assistant", - "content": [ - { - "type": "text", - "text": "Hello!" - } - ], - "model": "claude-2.1", - "stop_reason": "end_turn", - "stop_sequence": null, - "usage": { - "input_tokens": 12, - "output_tokens": 6 - } + case .error(let responseJSON): + client?.urlProtocol(self, didReceive: errorResponse, cacheStoragePolicy: .notAllowed) + guard + let data = responseJSON.data(using: .utf8) else { + client?.urlProtocol(self, didLoad: getBasicJSONStringData()) + return } - """ - let data = basicResponse.data(using: .utf8)! client?.urlProtocol(self, didLoad: data) } } @@ -70,6 +73,30 @@ public class HTTPMock: URLProtocol { client?.urlProtocolDidFinishLoading(self) } + private func getBasicJSONStringData() -> Data { + let basicResponse = """ + { + "id": "msg_01XFDUDYJgAACzvnptvVoYEL", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "text", + "text": "Hello!" + } + ], + "model": "claude-2.1", + "stop_reason": "end_turn", + "stop_sequence": null, + "usage": { + "input_tokens": 12, + "output_tokens": 6 + } + } + """ + return basicResponse.data(using: .utf8)! + } + public override func stopLoading() { } } diff --git a/Sources/AnthropicSwiftSDK-TestUtils/MessagesRequest+Extensions.swift b/Sources/AnthropicSwiftSDK-TestUtils/MessagesRequest+Extensions.swift index 8354c89..7744d4e 100644 --- a/Sources/AnthropicSwiftSDK-TestUtils/MessagesRequest+Extensions.swift +++ b/Sources/AnthropicSwiftSDK-TestUtils/MessagesRequest+Extensions.swift @@ -8,8 +8,17 @@ import Foundation import AnthropicSwiftSDK -extension MessagesRequest { - public static var nop: Self { - MessagesRequest(messages: [], maxTokens: 1024) +public struct NopRequest: Request { + public let method: HttpMethod + public let path: String + public let queries: [String : any CustomStringConvertible]? = nil + public let body: Never? = nil + + public init( + method: HttpMethod = .post, + path: String = "/v1/messages" + ) { + self.method = method + self.path = path } } diff --git a/Sources/AnthropicSwiftSDK/Anthropic.swift b/Sources/AnthropicSwiftSDK/Anthropic.swift index ff312d8..6d35b5c 100644 --- a/Sources/AnthropicSwiftSDK/Anthropic.swift +++ b/Sources/AnthropicSwiftSDK/Anthropic.swift @@ -12,9 +12,13 @@ public final class Anthropic { /// Messages API Interface public let messages: Messages + /// MessageBatches API Interface + public let messageBatches: MessageBatches + /// Construction of SDK /// - Parameter apiKey: API key to access Anthropic API. public init(apiKey: String) { self.messages = Messages(apiKey: apiKey, session: .shared) + self.messageBatches = MessageBatches(apiKey: apiKey, session: .shared) } } diff --git a/Sources/AnthropicSwiftSDK/Entity/Batch/BatchParameter.swift b/Sources/AnthropicSwiftSDK/Entity/Batch/BatchParameter.swift new file mode 100644 index 0000000..995f0dc --- /dev/null +++ b/Sources/AnthropicSwiftSDK/Entity/Batch/BatchParameter.swift @@ -0,0 +1,70 @@ +// +// BatchParameter.swift +// AnthropicSwiftSDK +// +// Created by 伊藤史 on 2024/10/18. +// + +import FunctionCalling + +public struct BatchParameter { + /// Input messages. + public let messages: [Message] + /// The model that will complete your prompt. + /// + /// See [models](https://docs.anthropic.com/en/docs/models-overview) for additional details and options. + public let model: Model + /// System prompt. + /// + /// A system prompt is a way of providing context and instructions to Claude, such as specifying a particular goal or role. + public let system: [SystemPrompt] + /// The maximum number of tokens to generate before stopping. + public let maxTokens: Int + /// An object describing metadata about the request. + public let metaData: MetaData? + /// Custom text sequences that will cause the model to stop generating. + public let stopSequence: [String]? + /// Whether the response should be handles as streaming or not, + /// + /// This parameter is always `false` + /// + /// - Note: Streaming is not supported for batch requests. + /// For more details, see https://docs.anthropic.com/en/docs/build-with-claude/message-batches#can-i-use-the-message-batches-api-with-other-api-features + public let stream: Bool = false + /// Amount of randomness injected into the response. + public let temperature: Double? + /// Use nucleus sampling. + public let topP: Double? + /// Only sample from the top K options for each subsequent token. + public let topK: Int? + /// Definitions of tools that the model may use. + public let toolContainer: ToolContainer? + /// How the model should use the provided tools. The model can use a specific tool, any available tool, or decide by itself. + public let toolChoice: ToolChoice + + public init( + messages: [Message], + model: Model = .claude_3_Opus, + system: [SystemPrompt] = [], + maxTokens: Int, + metaData: MetaData? = nil, + stopSequence: [String]? = nil, + temperature: Double? = nil, + topP: Double? = nil, + topK: Int? = nil, + toolContainer: ToolContainer? = nil, + toolChoice: ToolChoice = .auto + ) { + self.messages = messages + self.model = model + self.system = system + self.maxTokens = maxTokens + self.metaData = metaData + self.stopSequence = stopSequence + self.temperature = temperature + self.topP = topP + self.topK = topK + self.toolContainer = toolContainer + self.toolChoice = toolChoice + } +} diff --git a/Sources/AnthropicSwiftSDK/Entity/Batch/BatchRequestCounts.swift b/Sources/AnthropicSwiftSDK/Entity/Batch/BatchRequestCounts.swift new file mode 100644 index 0000000..ef8591c --- /dev/null +++ b/Sources/AnthropicSwiftSDK/Entity/Batch/BatchRequestCounts.swift @@ -0,0 +1,28 @@ +// +// BatchRequestCounts.swift +// AnthropicSwiftSDK +// +// Created by 伊藤史 on 2024/10/09. +// + +/// Tallies requests within the Message Batch, categorized by their status. +public struct BatchRequestCounts: Decodable { + /// Number of requests in the Message Batch that are processing. + let processing: Int + /// Number of requests in the Message Batch that have completed successfully. + /// + /// This is zero until processing of the entire Message Batch has ended. + let succeeeded: Int + /// Number of requests in the Message Batch that encountered an error. + /// + /// This is zero until processing of the entire Message Batch has ended. + let errored: Int + /// Number of requests in the Message Batch that have been canceled. + /// + /// This is zero until processing of the entire Message Batch has ended. + let canceled: Int + /// Number of requests in the Message Batch that have expired. + /// + /// This is zero until processing of the entire Message Batch has ended. + let expired: Int +} diff --git a/Sources/AnthropicSwiftSDK/Entity/Batch/BatchResultType.swift b/Sources/AnthropicSwiftSDK/Entity/Batch/BatchResultType.swift new file mode 100644 index 0000000..59ac1c7 --- /dev/null +++ b/Sources/AnthropicSwiftSDK/Entity/Batch/BatchResultType.swift @@ -0,0 +1,20 @@ +// +// BatchResultType.swift +// AnthropicSwiftSDK +// +// Created by 伊藤史 on 2024/10/09. +// + +/// Once batch processing has ended, each Messages request in the batch will have a result. +public enum BatchResultType: String, Decodable { + /// Request was successful. Includes the message result. + case succeeded + /// Request encountered an error and a message was not created. + /// + /// Possible errors include invalid requests and internal server errors. You will not be billed for these requests. + case errored + /// User canceled the batch before this request could be sent to the model. You will not be billed for these requests. + case cancelled + /// Batch reached its 24 hour expiration before this request could be sent to the model. You will not be billed for these requests. + case expired +} diff --git a/Sources/AnthropicSwiftSDK/Entity/Batch/BatchType.swift b/Sources/AnthropicSwiftSDK/Entity/Batch/BatchType.swift new file mode 100644 index 0000000..28c9cb7 --- /dev/null +++ b/Sources/AnthropicSwiftSDK/Entity/Batch/BatchType.swift @@ -0,0 +1,10 @@ +// +// BatchType.swift +// AnthropicSwiftSDK +// +// Created by 伊藤史 on 2024/10/09. +// + +public enum BatchType: String, RawRepresentable, Decodable { + case message = "message_batch" +} diff --git a/Sources/AnthropicSwiftSDK/Entity/Batch/MessageBatch.swift b/Sources/AnthropicSwiftSDK/Entity/Batch/MessageBatch.swift new file mode 100644 index 0000000..c5f2bfd --- /dev/null +++ b/Sources/AnthropicSwiftSDK/Entity/Batch/MessageBatch.swift @@ -0,0 +1,22 @@ +// +// MessageBatch.swift +// AnthropicSwiftSDK +// +// Created by 伊藤史 on 2024/10/18. +// + +public struct MessageBatch { + /// Developer-provided ID created for each request in a Message Batch. Useful for matching results to requests, as results may be given out of request order. + /// + /// Must be unique for each request within the Message Batch. + public let customId: String + /// Messages API creation parameters for the individual request. + /// + /// See the [Messages API](https://docs.anthropic.com/en/api/messages) reference for full documentation on available parameters. + public let parameter: BatchParameter + + public init(customId: String, parameter: BatchParameter) { + self.customId = customId + self.parameter = parameter + } +} diff --git a/Sources/AnthropicSwiftSDK/Entity/Batch/ProcessingStatus.swift b/Sources/AnthropicSwiftSDK/Entity/Batch/ProcessingStatus.swift new file mode 100644 index 0000000..4ac9363 --- /dev/null +++ b/Sources/AnthropicSwiftSDK/Entity/Batch/ProcessingStatus.swift @@ -0,0 +1,13 @@ +// +// ProcessingStatus.swift +// AnthropicSwiftSDK +// +// Created by 伊藤史 on 2024/10/09. +// + +/// Processing status of the Message Batch. +public enum ProcessingStatus: String, RawRepresentable, Decodable { + case inProgress = "in_progress" + case canceling + case ended +} diff --git a/Sources/AnthropicSwiftSDK/MessageBatches.swift b/Sources/AnthropicSwiftSDK/MessageBatches.swift new file mode 100644 index 0000000..3e9774c --- /dev/null +++ b/Sources/AnthropicSwiftSDK/MessageBatches.swift @@ -0,0 +1,339 @@ +// +// Batches.swift +// AnthropicSwiftSDK +// +// Created by 伊藤史 on 2024/10/09. +// + +import Foundation +import FunctionCalling +import SwiftyJSONLines + +/// A class responsible for managing message batches in the Anthropic API. +public struct MessageBatches { + /// The API key used for authentication with the Anthropic API. + private let apiKey: String + /// The URL session used for network requests. + private let session: URLSession + + /// Initializes a new instance of `MessageBatches`. + /// + /// - Parameters: + /// - apiKey: The API key for authentication. + /// - session: The URL session for network requests. + init(apiKey: String, session: URLSession) { + self.apiKey = apiKey + self.session = session + } + + /// Creates new message batches. + /// + /// - Parameter batches: An array of `MessageBatch` to be created. + /// - Returns: A `BatchResponse` containing the details of the created batches. + /// - Throws: An error if the request fails. + public func createBatches(batches: [MessageBatch]) async throws -> BatchResponse { + try await createBatches( + batches: batches, + anthropicHeaderProvider: DefaultAnthropicHeaderProvider(), + authenticationHeaderProvider: APIKeyAuthenticationHeaderProvider(apiKey: apiKey) + ) + } + + /// Creates new message batches with custom header providers. + /// + /// - Parameters: + /// - batches: An array of `MessageBatch` to be created. + /// - anthropicHeaderProvider: The provider for Anthropic-specific headers. + /// - authenticationHeaderProvider: The provider for authentication headers. + /// - Returns: A `BatchResponse` containing the details of the created batches. + /// - Throws: An error if the request fails. + public func createBatches( + batches: [MessageBatch], + anthropicHeaderProvider: AnthropicHeaderProvider, + authenticationHeaderProvider: AuthenticationHeaderProvider + ) async throws -> BatchResponse { + let client = APIClient( + session: session, + anthropicHeaderProvider: anthropicHeaderProvider, + authenticationHeaderProvider: authenticationHeaderProvider + ) + + let request = MessageBatchesRequest(body: .init(from: batches)) + let (data, response) = try await client.send(request: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw ClientError.cannotHandleURLResponse(response) + } + + guard httpResponse.statusCode == 200 else { + throw AnthropicAPIError(fromHttpStatusCode: httpResponse.statusCode) + } + + return try anthropicJSONDecoder.decode(BatchResponse.self, from: data) + } + + /// Retrieves a specific message batch by its ID. + /// + /// - Parameter batchId: The ID of the batch to retrieve. + /// - Returns: A `BatchResponse` containing the details of the retrieved batch. + /// - Throws: An error if the request fails. + public func retrieve(batchId: String) async throws -> BatchResponse { + try await retrieve( + batchId: batchId, + anthropicHeaderProvider: DefaultAnthropicHeaderProvider(), + authenticationHeaderProvider: APIKeyAuthenticationHeaderProvider(apiKey: apiKey) + ) + } + + /// Retrieves a specific message batch by its ID with custom header providers. + /// + /// - Parameters: + /// - batchId: The ID of the batch to retrieve. + /// - anthropicHeaderProvider: The provider for Anthropic-specific headers. + /// - authenticationHeaderProvider: The provider for authentication headers. + /// - Returns: A `BatchResponse` containing the details of the retrieved batch. + /// - Throws: An error if the request fails. + public func retrieve( + batchId: String, + anthropicHeaderProvider: AnthropicHeaderProvider, + authenticationHeaderProvider: AuthenticationHeaderProvider + ) async throws -> BatchResponse { + let client = APIClient( + session: session, + anthropicHeaderProvider: anthropicHeaderProvider, + authenticationHeaderProvider: authenticationHeaderProvider + ) + + let request = RetrieveMessageBatchesRequest(batchId: batchId) + let (data, response) = try await client.send(request: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw ClientError.cannotHandleURLResponse(response) + } + + guard httpResponse.statusCode == 200 else { + throw AnthropicAPIError(fromHttpStatusCode: httpResponse.statusCode) + } + + return try anthropicJSONDecoder.decode(BatchResponse.self, from: data) + } + + /// Retrieves the results of a specific message batch by its ID. + /// + /// - Parameter batchId: The ID of the batch to retrieve results for. + /// - Returns: An array of `BatchResultResponse` containing the results of the batch. + /// - Throws: An error if the request fails. + public func results(of batchId: String) async throws -> [BatchResultResponse] { + try await results( + of: batchId, + anthropicHeaderProvider: DefaultAnthropicHeaderProvider(), + authenticationHeaderProvider: APIKeyAuthenticationHeaderProvider(apiKey: apiKey) + ) + } + + /// Retrieves the results of a specific message batch by its ID with custom header providers. + /// + /// - Parameters: + /// - batchId: The ID of the batch to retrieve results for. + /// - anthropicHeaderProvider: The provider for Anthropic-specific headers. + /// - authenticationHeaderProvider: The provider for authentication headers. + /// - Returns: An array of `BatchResultResponse` containing the results of the batch. + /// - Throws: An error if the request fails. + public func results( + of batchId: String, + anthropicHeaderProvider: AnthropicHeaderProvider, + authenticationHeaderProvider: AuthenticationHeaderProvider + ) async throws -> [BatchResultResponse] { + let client = APIClient( + session: session, + anthropicHeaderProvider: anthropicHeaderProvider, + authenticationHeaderProvider: authenticationHeaderProvider + ) + + let request = RetrieveMessageBatchResultsRequest(batchId: batchId) + let (data, response) = try await client.send(request: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw ClientError.cannotHandleURLResponse(response) + } + + guard httpResponse.statusCode == 200 else { + throw AnthropicAPIError(fromHttpStatusCode: httpResponse.statusCode) + } + + return try JSONLines(data: data).toObjects(with: anthropicJSONDecoder) + } + + /// Streams the results of a specific message batch by its ID. + /// + /// - Parameter batchId: The ID of the batch to stream results for. + /// - Returns: An `AsyncThrowingStream` of `BatchResultResponse` containing the streamed results of the batch. + /// - Throws: An error if the request fails. + public func results(streamOf batchId: String) async throws -> AsyncThrowingStream { + try await results( + streamOf: batchId, + anthropicHeaderProvider: DefaultAnthropicHeaderProvider(), + authenticationHeaderProvider: APIKeyAuthenticationHeaderProvider(apiKey: apiKey) + ) + } + + /// Streams the results of a specific message batch by its ID with custom header providers. + /// + /// - Parameters: + /// - batchId: The ID of the batch to stream results for. + /// - anthropicHeaderProvider: The provider for Anthropic-specific headers. + /// - authenticationHeaderProvider: The provider for authentication headers. + /// - Returns: An `AsyncThrowingStream` of `BatchResultResponse` containing the streamed results of the batch. + /// - Throws: An error if the request fails. + public func results( + streamOf batchId: String, + anthropicHeaderProvider: AnthropicHeaderProvider, + authenticationHeaderProvider: AuthenticationHeaderProvider + ) async throws -> AsyncThrowingStream { + let client = APIClient( + session: session, + anthropicHeaderProvider: anthropicHeaderProvider, + authenticationHeaderProvider: authenticationHeaderProvider + ) + + let request = RetrieveMessageBatchResultsRequest(batchId: batchId) + let (data, response) = try await client.stream(request: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw ClientError.cannotHandleURLResponse(response) + } + + guard httpResponse.statusCode == 200 else { + throw AnthropicAPIError(fromHttpStatusCode: httpResponse.statusCode) + } + + return AsyncThrowingStream.init { continuation in + let task = Task { + for try await line in data.lines { + guard let data = line.data(using: .utf8) else { + return + } + + continuation.yield(try anthropicJSONDecoder.decode(BatchResultResponse.self, from: data)) + } + continuation.finish() + } + continuation.onTermination = { @Sendable _ in + task.cancel() + } + } + } + + /// Lists message batches with optional pagination. + /// + /// - Parameters: + /// - beforeId: The ID to list batches before. + /// - afterId: The ID to list batches after. + /// - limit: The maximum number of batches to list. + /// - Returns: A `BatchListResponse` containing the list of batches. + /// - Throws: An error if the request fails. + public func list(beforeId: String? = nil, afterId: String? = nil, limit: Int = 20) async throws -> BatchListResponse { + try await list( + beforeId: beforeId, + afterId: afterId, + limit: limit, + anthropicHeaderProvider: DefaultAnthropicHeaderProvider(), + authenticationHeaderProvider: APIKeyAuthenticationHeaderProvider(apiKey: apiKey) + ) + } + + /// Lists message batches with optional pagination and custom header providers. + /// + /// - Parameters: + /// - beforeId: The ID to list batches before. + /// - afterId: The ID to list batches after. + /// - limit: The maximum number of batches to list. + /// - anthropicHeaderProvider: The provider for Anthropic-specific headers. + /// - authenticationHeaderProvider: The provider for authentication headers. + /// - Returns: A `BatchListResponse` containing the list of batches. + /// - Throws: An error if the request fails. + public func list( + beforeId: String?, + afterId: String?, + limit: Int, + anthropicHeaderProvider: AnthropicHeaderProvider, + authenticationHeaderProvider: AuthenticationHeaderProvider + ) async throws -> BatchListResponse { + let client = APIClient( + session: session, + anthropicHeaderProvider: anthropicHeaderProvider, + authenticationHeaderProvider: authenticationHeaderProvider + ) + + let queries: [String: CustomStringConvertible] = { + var queries: [String: CustomStringConvertible] = [ListMessageBatchesRequest.Parameter.limit.rawValue: limit] + if let beforeId { + queries[ListMessageBatchesRequest.Parameter.beforeId.rawValue] = beforeId + } + if let afterId { + queries[ListMessageBatchesRequest.Parameter.afterId.rawValue] = afterId + } + + return queries + }() + + let request = ListMessageBatchesRequest(queries: queries) + let (data, response) = try await client.send(request: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw ClientError.cannotHandleURLResponse(response) + } + + guard httpResponse.statusCode == 200 else { + throw AnthropicAPIError(fromHttpStatusCode: httpResponse.statusCode) + } + + return try anthropicJSONDecoder.decode(BatchListResponse.self, from: data) + } + + /// Cancels a specific message batch by its ID. + /// + /// - Parameter batchId: The ID of the batch to cancel. + /// - Returns: A `BatchResponse` containing the details of the canceled batch. + /// - Throws: An error if the request fails. + public func cancel(batchId: String) async throws -> BatchResponse { + try await cancel( + batchId: batchId, + anthropicHeaderProvider: DefaultAnthropicHeaderProvider(), + authenticationHeaderProvider: APIKeyAuthenticationHeaderProvider(apiKey: apiKey) + ) + } + + /// Cancels a specific message batch by its ID with custom header providers. + /// + /// - Parameters: + /// - batchId: The ID of the batch to cancel. + /// - anthropicHeaderProvider: The provider for Anthropic-specific headers. + /// - authenticationHeaderProvider: The provider for authentication headers. + /// - Returns: A `BatchResponse` containing the details of the canceled batch. + /// - Throws: An error if the request fails. + public func cancel( + batchId: String, + anthropicHeaderProvider: AnthropicHeaderProvider, + authenticationHeaderProvider: AuthenticationHeaderProvider + ) async throws -> BatchResponse { + let client = APIClient( + session: session, + anthropicHeaderProvider: anthropicHeaderProvider, + authenticationHeaderProvider: authenticationHeaderProvider + ) + + let request = CancelMessageBatchRequest(batchId: batchId) + let (data, response) = try await client.send(request: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw ClientError.cannotHandleURLResponse(response) + } + + guard httpResponse.statusCode == 200 else { + throw AnthropicAPIError(fromHttpStatusCode: httpResponse.statusCode) + } + + return try anthropicJSONDecoder.decode(BatchResponse.self, from: data) + } +} diff --git a/Sources/AnthropicSwiftSDK/Messages.swift b/Sources/AnthropicSwiftSDK/Messages.swift index 50df9d5..0a0667f 100644 --- a/Sources/AnthropicSwiftSDK/Messages.swift +++ b/Sources/AnthropicSwiftSDK/Messages.swift @@ -99,28 +99,30 @@ public struct Messages { anthropicHeaderProvider: AnthropicHeaderProvider, authenticationHeaderProvider: AuthenticationHeaderProvider ) async throws -> MessagesResponse { - let client = AnthropicAPIClient( + let client = APIClient( + session: session, anthropicHeaderProvider: anthropicHeaderProvider, - authenticationHeaderProvider: authenticationHeaderProvider, - session: session + authenticationHeaderProvider: authenticationHeaderProvider ) - let requestBody = MessagesRequest( - model: model, - messages: messages, - system: system, - maxTokens: maxTokens, - metaData: metaData, - stopSequences: stopSequence, - stream: false, - temperature: temperature, - topP: topP, - topK: topK, - tools: toolContainer?.allTools, - toolChoice: toolChoice + let request = MessagesRequest( + body: .init( + model: model, + messages: messages, + system: system, + maxTokens: maxTokens, + metaData: metaData, + stopSequences: stopSequence, + stream: false, + temperature: temperature, + topP: topP, + topK: topK, + tools: toolContainer?.allTools, + toolChoice: toolChoice + ) ) - let (data, response) = try await client.send(requestBody: requestBody) + let (data, response) = try await client.send(request: request) guard let httpResponse = response as? HTTPURLResponse else { throw ClientError.cannotHandleURLResponse(response) @@ -300,28 +302,30 @@ public struct Messages { anthropicHeaderProvider: AnthropicHeaderProvider, authenticationHeaderProvider: AuthenticationHeaderProvider ) async throws -> AsyncThrowingStream { - let client = AnthropicAPIClient( + let client = APIClient( + session: session, anthropicHeaderProvider: anthropicHeaderProvider, - authenticationHeaderProvider: authenticationHeaderProvider, - session: session + authenticationHeaderProvider: authenticationHeaderProvider ) - let requestBody = MessagesRequest( - model: model, - messages: messages, - system: system, - maxTokens: maxTokens, - metaData: metaData, - stopSequences: stopSequence, - stream: true, - temperature: temperature, - topP: topP, - topK: topK, - tools: toolContainer?.allTools, - toolChoice: toolChoice + let request = MessagesRequest( + body: .init( + model: model, + messages: messages, + system: system, + maxTokens: maxTokens, + metaData: metaData, + stopSequences: stopSequence, + stream: true, + temperature: temperature, + topP: topP, + topK: topK, + tools: toolContainer?.allTools, + toolChoice: toolChoice + ) ) - let (data, response) = try await client.stream(requestBody: requestBody) + let (data, response) = try await client.stream(request: request) guard let httpResponse = response as? HTTPURLResponse else { throw ClientError.cannotHandleURLResponse(response) diff --git a/Sources/AnthropicSwiftSDK/Network/AnthropicAPIClient.swift b/Sources/AnthropicSwiftSDK/Network/AnthropicAPIClient.swift index 4037f97..5cfcbcb 100644 --- a/Sources/AnthropicSwiftSDK/Network/AnthropicAPIClient.swift +++ b/Sources/AnthropicSwiftSDK/Network/AnthropicAPIClient.swift @@ -7,35 +7,23 @@ import Foundation -enum HttpMethod: String { - case post = "POST" -} - -enum APIType: String { - case messages -// case textCompletions - - var path: String { - switch self { - case .messages: - return "/v1/messages" - } - } - - var method: HttpMethod { - switch self { - case .messages: - return .post - } - } -} - -struct AnthropicAPIClient { - let endpoint = "https://api.anthropic.com" - let type: APIType +struct APIClient { + private let session: URLSession + let baseURL: URL let anthropicHeaderProvider: AnthropicHeaderProvider let authenticationHeaderProvider: AuthenticationHeaderProvider - private let session: URLSession + + init( + session: URLSession = .shared, + baseURL: URL = .init(string: "https://api.anthropic.com")!, // swiftlint:disable:this force_unwrapping + anthropicHeaderProvider: AnthropicHeaderProvider, + authenticationHeaderProvider: AuthenticationHeaderProvider + ) { + self.session = session + self.baseURL = baseURL + self.anthropicHeaderProvider = anthropicHeaderProvider + self.authenticationHeaderProvider = authenticationHeaderProvider + } private var headers: [String: String] { var headers: [String: String] = [:] @@ -51,65 +39,29 @@ struct AnthropicAPIClient { return headers } - private var requestURL: URL? { - guard var urlComponents = URLComponents(string: endpoint) else { - return nil - } - - urlComponents.path = type.path - - return urlComponents.url - } - - private var urlRequest: URLRequest { - guard let url = requestURL else { - fatalError("APIClient must have requestURL") - } - - return URLRequest(url: url) - } - - init( - type: APIType = .messages, - anthropicHeaderProvider: AnthropicHeaderProvider, - authenticationHeaderProvider: AuthenticationHeaderProvider, - session: URLSession - ) { - self.type = type - self.anthropicHeaderProvider = anthropicHeaderProvider - self.authenticationHeaderProvider = authenticationHeaderProvider - self.session = session - } - /// Send messages API request. This method receives HTTP response from API. /// - /// For more detail, see https://docs.anthropic.com/claude/reference/messages_post. - /// - Parameter requestBody: request body for api request + /// - Parameter request: request body for api request /// - Returns: response from API - func send(requestBody: MessagesRequest) async throws -> (Data, URLResponse) { - var request = urlRequest + func send(request: any Request) async throws -> (Data, URLResponse) { + var urlRequest = try request.toURLRequest(for: baseURL) headers.forEach { key, value in - request.setValue(value, forHTTPHeaderField: key) + urlRequest.setValue(value, forHTTPHeaderField: key) } - request.httpMethod = type.method.rawValue - request.httpBody = try anthropicJSONEncoder.encode(requestBody) - return try await session.data(for: request) + return try await session.data(for: urlRequest) } - /// Send messages API request. This method read the messages api response sequentially. + /// Send messages API request. This method read the API response sequentially. /// - /// For more detail, see https://docs.anthropic.com/claude/reference/messages-streaming. - /// - Parameter requestBody: request body for api request + /// - Parameter request: request body for api request /// - Returns: response chunk from API - func stream(requestBody: MessagesRequest) async throws -> (URLSession.AsyncBytes, URLResponse) { - var request = urlRequest + func stream(request: any Request) async throws -> (URLSession.AsyncBytes, URLResponse) { + var urlRequest = try request.toURLRequest(for: baseURL) headers.forEach { key, value in - request.setValue(value, forHTTPHeaderField: key) + urlRequest.setValue(value, forHTTPHeaderField: key) } - request.httpMethod = type.method.rawValue - request.httpBody = try anthropicJSONEncoder.encode(requestBody) - return try await session.bytes(for: request) + return try await session.bytes(for: urlRequest) } } diff --git a/Sources/AnthropicSwiftSDK/Network/HeaderProvider/AnthropicHeaderProvider.swift b/Sources/AnthropicSwiftSDK/Network/HeaderProvider/AnthropicHeaderProvider.swift index 543f235..a51e516 100644 --- a/Sources/AnthropicSwiftSDK/Network/HeaderProvider/AnthropicHeaderProvider.swift +++ b/Sources/AnthropicSwiftSDK/Network/HeaderProvider/AnthropicHeaderProvider.swift @@ -20,7 +20,7 @@ struct DefaultAnthropicHeaderProvider: AnthropicHeaderProvider { /// content type of response, now only support JSON let contentType = "application/json" - private let betaDescription = "prompt-caching-2024-07-31" + private let betaDescription = "message-batches-2024-09-24" func getAnthropicAPIHeaders() -> [String: String] { var headers: [String: String] = [ diff --git a/Sources/AnthropicSwiftSDK/Network/Request/CancelMessageBatchRequest.swift b/Sources/AnthropicSwiftSDK/Network/Request/CancelMessageBatchRequest.swift new file mode 100644 index 0000000..cb418ef --- /dev/null +++ b/Sources/AnthropicSwiftSDK/Network/Request/CancelMessageBatchRequest.swift @@ -0,0 +1,21 @@ +// +// CancelMessageBatchRequest.swift +// AnthropicSwiftSDK +// +// Created by 伊藤史 on 2024/10/16. +// + +/// Batches may be canceled any time before processing ends. +/// +/// Once cancellation is initiated, the batch enters a canceling state, at which time the system may complete any in-progress, non-interruptible requests before finalizing cancellation. +/// The number of canceled requests is specified in request_counts. To determine which requests were canceled, check the individual results within the batch. Note that cancellation may not result in any canceled requests if they were non-interruptible. +struct CancelMessageBatchRequest: Request { + let method: HttpMethod = .post + var path: String { + "\(RequestType.batches.basePath)/\(batchId)/cancel" + } + let queries: [String: CustomStringConvertible]? = nil + let body: Never? = nil + /// ID of the Message Batch. + let batchId: String +} diff --git a/Sources/AnthropicSwiftSDK/Network/Request/ListMessageBatchesRequest.swift b/Sources/AnthropicSwiftSDK/Network/Request/ListMessageBatchesRequest.swift new file mode 100644 index 0000000..59fdcc5 --- /dev/null +++ b/Sources/AnthropicSwiftSDK/Network/Request/ListMessageBatchesRequest.swift @@ -0,0 +1,25 @@ +// +// ListMessageBatchesRequest.swift +// AnthropicSwiftSDK +// +// Created by 伊藤史 on 2024/10/16. +// + +/// List all Message Batches within a Workspace. Most recently created batches are returned first. +struct ListMessageBatchesRequest: Request { + let method: HttpMethod = .get + let path: String = RequestType.batches.basePath + let queries: [String: CustomStringConvertible]? + let body: Never? = nil + + enum Parameter: String { + /// ID of the object to use as a cursor for pagination. When provided, returns the page of results immediately before this object. + case beforeId = "before_id" + /// ID of the object to use as a cursor for pagination. When provided, returns the page of results immediately after this object. + case afterId = "after_id" + /// Number of items to return per page. + /// + /// Defaults to 20. Ranges from 1 to 100. + case limit + } +} diff --git a/Sources/AnthropicSwiftSDK/Network/Request/MessageBatchesRequest.swift b/Sources/AnthropicSwiftSDK/Network/Request/MessageBatchesRequest.swift new file mode 100644 index 0000000..5adcb34 --- /dev/null +++ b/Sources/AnthropicSwiftSDK/Network/Request/MessageBatchesRequest.swift @@ -0,0 +1,50 @@ +// +// MessageBatchesRequest.swift +// AnthropicSwiftSDK +// +// Created by 伊藤史 on 2024/10/09. +// + +/// The Message Batches API can be used to process multiple Messages API requests at once. +/// +/// Once a Message Batch is created, it begins processing immediately. +/// Batches can take up to 24 hours to complete. +/// +/// The Message Batches API supports the following models: Claude 3 Haiku, Claude 3 Opus, and Claude 3.5 Sonnet. +/// All features available in the Messages API, including beta features, are available through the Message Batches API. +/// While in beta, batches may contain up to 10,000 requests and be up to 32 MB in total size. +struct MessageBatchesRequest: Request { + typealias Body = MessageBatchesRequestBody + + let method: HttpMethod = .post + let path: String = RequestType.batches.basePath + let queries: [String: CustomStringConvertible]? = nil + let body: MessageBatchesRequestBody? +} + +// MARK: Request Body + +struct MessageBatchesRequestBody: Encodable { + /// List of requests for prompt completion. Each is an individual request to create a Message. + let requests: [Batch] + + init(from batches: [MessageBatch]) { + self.requests = batches.map { Batch(from: $0) } + } +} + +struct Batch: Encodable { + /// Developer-provided ID created for each request in a Message Batch. Useful for matching results to requests, as results may be given out of request order. + /// + /// Must be unique for each request within the Message Batch. + let customId: String + /// Messages API creation parameters for the individual request. + /// + /// See the [Messages API reference](https://docs.anthropic.com/en/api/messages) for full documentation on available parameters. + let params: MessagesRequestBody + + init(from batch: MessageBatch) { + self.customId = batch.customId + self.params = MessagesRequestBody(from: batch.parameter) + } +} diff --git a/Sources/AnthropicSwiftSDK/Network/MessagesRequest.swift b/Sources/AnthropicSwiftSDK/Network/Request/MessagesRequest.swift similarity index 72% rename from Sources/AnthropicSwiftSDK/Network/MessagesRequest.swift rename to Sources/AnthropicSwiftSDK/Network/Request/MessagesRequest.swift index d26acc5..012a054 100644 --- a/Sources/AnthropicSwiftSDK/Network/MessagesRequest.swift +++ b/Sources/AnthropicSwiftSDK/Network/Request/MessagesRequest.swift @@ -8,52 +8,63 @@ import Foundation import FunctionCalling +struct MessagesRequest: Request { + typealias Body = MessagesRequestBody + + let method: HttpMethod = .post + let path: String = RequestType.messages.basePath + let queries: [String: CustomStringConvertible]? = nil + let body: Body? +} + +// MARK: Request Body + /// Request object for Messages API /// /// a structured list of input messages with text and/or image content, and the model will generate the next message in the conversation. -public struct MessagesRequest: Encodable { +struct MessagesRequestBody: Encodable { /// The model that will complete your prompt. - public let model: Model + let model: Model /// Input messages. - public let messages: [Message] + let messages: [Message] /// System prompt. /// /// A system prompt is a way of providing context and instructions to Claude, such as specifying a particular goal or role. - public let system: [SystemPrompt] + let system: [SystemPrompt] /// The maximum number of tokens to generate before stopping. /// /// Note that our models may stop before reaching this maximum. This parameter only specifies the absolute maximum number of tokens to generate. /// Different models have different maximum values for this parameter. - public let maxTokens: Int + let maxTokens: Int /// An object describing metadata about the request. - public let metaData: MetaData? + let metaData: MetaData? /// Custom text sequences that will cause the model to stop generating. - public let stopSequences: [String]? + let stopSequences: [String]? /// Whether to incrementally stream the response using server-sent events. /// /// see [streaming](https://docs.anthropic.com/claude/reference/messages-streaming) for more detail. - public let stream: Bool + let stream: Bool /// Amount of randomness injected into the response. /// /// Defaults to 1.0. Ranges from 0.0 to 1.0. Use temperature closer to 0.0 for analytical / multiple choice, and closer to 1.0 for creative and generative tasks. /// Note that even with temperature of 0.0, the results will not be fully deterministic. - public let temperature: Double? + let temperature: Double? /// Use nucleus sampling. /// /// In nucleus sampling, we compute the cumulative distribution over all the options for each subsequent token in decreasing probability order and cut it off once it reaches a particular probability specified by top_p. You should either alter temperature or top_p, but not both. /// Recommended for advanced use cases only. You usually only need to use temperature. - public let topP: Double? + let topP: Double? /// Only sample from the top K options for each subsequent token. /// /// Used to remove "long tail" low probability responses. /// Recommended for advanced use cases only. You usually only need to use temperature. - public let topK: Int? + let topK: Int? /// Definition of tools with names, descriptions, and input schemas in your API request. - public let tools: [Tool]? + let tools: [Tool]? /// Definition whether or not to force Claude to use the tool. ToolChoice should be set if tools are specified. - public let toolChoice: ToolChoice? + let toolChoice: ToolChoice? - public init( + init( model: Model = .claude_3_Opus, messages: [Message], system: [SystemPrompt] = [], @@ -80,23 +91,19 @@ public struct MessagesRequest: Encodable { self.tools = tools self.toolChoice = tools == nil ? nil : toolChoice // ToolChoice should be set if tools are specified. } -} - -extension MessagesRequest { - public func encode(with appendingObject: [String: Any], without removingObjectKeys: [String] = []) throws -> Data { - let encoded = try anthropicJSONEncoder.encode(self) - guard var dictionary = try JSONSerialization.jsonObject(with: encoded, options: []) as? [String: Any] else { - return encoded - } - - appendingObject.forEach { key, value in - dictionary[key] = value - } - - removingObjectKeys.forEach { key in - dictionary.removeValue(forKey: key) - } - return try JSONSerialization.data(withJSONObject: dictionary, options: []) + init(from parameter: BatchParameter) { + self.model = parameter.model + self.messages = parameter.messages + self.system = parameter.system + self.maxTokens = parameter.maxTokens + self.metaData = parameter.metaData + self.stopSequences = parameter.stopSequence + self.stream = parameter.stream + self.temperature = parameter.temperature + self.topP = parameter.topP + self.topK = parameter.topK + self.tools = parameter.toolContainer?.allTools + self.toolChoice = parameter.toolContainer?.allTools == nil ? nil : parameter.toolChoice } } diff --git a/Sources/AnthropicSwiftSDK/Network/Request/Request.swift b/Sources/AnthropicSwiftSDK/Network/Request/Request.swift new file mode 100644 index 0000000..2b4f831 --- /dev/null +++ b/Sources/AnthropicSwiftSDK/Network/Request/Request.swift @@ -0,0 +1,54 @@ +// +// Request.swift +// AnthropicSwiftSDK +// +// Created by 伊藤史 on 2024/10/13. +// + +import Foundation + +public enum HttpMethod: String { + case post = "POST" + case get = "GET" +} + +enum RequestType { + case messages + case batches + + var basePath: String { + switch self { + case .messages: + return "/v1/messages" + case .batches: + return "/v1/messages/batches" + } + } +} + +public protocol Request { + associatedtype Body: Encodable + + var method: HttpMethod { get } + var path: String { get } + var queries: [String: CustomStringConvertible]? { get } + var body: Body? { get } +} + +extension Request { + func toURLRequest(for baseURL: URL) throws -> URLRequest { + let url = baseURL.appendingPathComponent(path) + + var request = URLRequest(url: url) + request.httpMethod = method.rawValue + if let body { + request.httpBody = try anthropicJSONEncoder.encode(body) + } + + if let queries { + request.url?.append(queryItems: queries.map { .init(name: $0.key, value: $0.value.description) }) + } + + return request + } +} diff --git a/Sources/AnthropicSwiftSDK/Network/Request/RetrieveMessageBatchResultsRequest.swift b/Sources/AnthropicSwiftSDK/Network/Request/RetrieveMessageBatchResultsRequest.swift new file mode 100644 index 0000000..a038f80 --- /dev/null +++ b/Sources/AnthropicSwiftSDK/Network/Request/RetrieveMessageBatchResultsRequest.swift @@ -0,0 +1,24 @@ +// +// RetrieveMessageBatchResultsRequest.swift +// AnthropicSwiftSDK +// +// Created by 伊藤史 on 2024/10/16. +// + +/// Streams the results of a Message Batch as a .jsonl file. +/// +/// Each line in the file is a JSON object containing the result of a single request in the Message Batch. +/// Results are not guaranteed to be in the same order as requests. Use the custom_id field to match results to requests. +/// +/// While in beta, this endpoint requires passing the anthropic-beta header with value message-batches-2024-09-24 +struct RetrieveMessageBatchResultsRequest: Request { + let method: HttpMethod = .get + var path: String { + "\(RequestType.batches.basePath)/\(batchId)/results" + } + let queries: [String: CustomStringConvertible]? = nil + let body: Never? = nil + + /// ID of the Message Batch. + let batchId: String +} diff --git a/Sources/AnthropicSwiftSDK/Network/Request/RetrieveMessageBatchesRequest.swift b/Sources/AnthropicSwiftSDK/Network/Request/RetrieveMessageBatchesRequest.swift new file mode 100644 index 0000000..c35557a --- /dev/null +++ b/Sources/AnthropicSwiftSDK/Network/Request/RetrieveMessageBatchesRequest.swift @@ -0,0 +1,20 @@ +// +// RetrieveMessageBatchesRequest.swift +// AnthropicSwiftSDK +// +// Created by 伊藤史 on 2024/10/16. +// + +/// This endpoint is idempotent and can be used to poll for Message Batch completion. +/// To access the results of a Message Batch, make a request to the results_url field in the response. +struct RetrieveMessageBatchesRequest: Request { + let method: HttpMethod = .get + var path: String { + "\(RequestType.batches.basePath)/\(batchId)" + } + let queries: [String: CustomStringConvertible]? = nil + let body: Never? = nil + + /// ID of the Message Batch. + let batchId: String +} diff --git a/Sources/AnthropicSwiftSDK/Network/Response/BatchListResponse.swift b/Sources/AnthropicSwiftSDK/Network/Response/BatchListResponse.swift new file mode 100644 index 0000000..a73ca81 --- /dev/null +++ b/Sources/AnthropicSwiftSDK/Network/Response/BatchListResponse.swift @@ -0,0 +1,18 @@ +// +// BatchListResponse.swift +// AnthropicSwiftSDK +// +// Created by 伊藤史 on 2024/10/09. +// + +/// https://docs.anthropic.com/en/api/listing-message-batches +public struct BatchListResponse: Decodable { + /// List of `BatchResponse` object. + public let data: [BatchResponse] + /// Indicates if there are more results in the requested page direction. + public let hasMore: Bool + /// First ID in the `data` list. Can be used as the `before_id` for the previous page. + public let firstId: String? + /// Last ID in the `data` list. Can be used as the `after_id` for the next page. + public let lastId: String? +} diff --git a/Sources/AnthropicSwiftSDK/Network/Response/BatchResponse.swift b/Sources/AnthropicSwiftSDK/Network/Response/BatchResponse.swift new file mode 100644 index 0000000..18ca163 --- /dev/null +++ b/Sources/AnthropicSwiftSDK/Network/Response/BatchResponse.swift @@ -0,0 +1,38 @@ +// +// BatchResponse.swift +// AnthropicSwiftSDK +// +// Created by 伊藤史 on 2024/10/09. +// + +/// https://docs.anthropic.com/en/api/creating-message-batches +public struct BatchResponse: Decodable { + /// Unique object identifier. + /// + /// The format and length of IDs may change over time. + public let id: String + /// Object type. + /// + /// For Message Batches, this is always "message_batch". + public let type: BatchType + /// Processing status of the Message Batch. + public let processingStatus: ProcessingStatus + /// Tallies requests within the Message Batch, categorized by their status. + /// + /// Requests start as processing and move to one of the other statuses only once processing of the entire batch ends. The sum of all values always matches the total number of requests in the batch. + public let requestCounts: BatchRequestCounts + /// RFC 3339 datetime string representing the time at which processing for the Message Batch ended. Specified only once processing ends. + /// + /// Processing ends when every request in a Message Batch has either succeeded, errored, canceled, or expired. + public let endedAt: String? + /// RFC 3339 datetime string representing the time at which the Message Batch was created. + public let createdAt: String + /// RFC 3339 datetime string representing the time at which the Message Batch will expire and end processing, which is 24 hours after creation. + public let expiresAt: String + /// RFC 3339 datetime string representing the time at which cancellation was initiated for the Message Batch. Specified only if cancellation was initiated. + public let cancelInitiatedAt: String? + /// URL to a .jsonl file containing the results of the Message Batch requests. Specified only once processing ends. + /// + /// Results in the file are not guaranteed to be in the same order as requests. Use the custom_id field to match results to requests. + public let resultsUrl: String? +} diff --git a/Sources/AnthropicSwiftSDK/Network/Response/BatchResultResponse.swift b/Sources/AnthropicSwiftSDK/Network/Response/BatchResultResponse.swift new file mode 100644 index 0000000..9a2164f --- /dev/null +++ b/Sources/AnthropicSwiftSDK/Network/Response/BatchResultResponse.swift @@ -0,0 +1,22 @@ +// +// BatchResultResponse.swift +// AnthropicSwiftSDK +// +// Created by 伊藤史 on 2024/10/18. +// + +public struct BatchResult: Decodable { + public let type: BatchResultType + public let message: MessagesResponse? + public let error: StreamingError? +} + +/// The results will be in .jsonl format, where each line is a valid JSON object representing the result of a single request in the Message Batch. +/// +/// For each streamed result, you can do something different depending on its custom_id and result type. +public struct BatchResultResponse: Decodable { + /// ID of the request. + public let customId: String + /// Result of the request. + public let result: BatchResult? +} diff --git a/Sources/AnthropicSwiftSDK/Network/MessagesResponse.swift b/Sources/AnthropicSwiftSDK/Network/Response/MessagesResponse.swift similarity index 100% rename from Sources/AnthropicSwiftSDK/Network/MessagesResponse.swift rename to Sources/AnthropicSwiftSDK/Network/Response/MessagesResponse.swift diff --git a/Sources/AnthropicSwiftSDK/Network/StreamingResponse.swift b/Sources/AnthropicSwiftSDK/Network/Response/StreamingResponse.swift similarity index 100% rename from Sources/AnthropicSwiftSDK/Network/StreamingResponse.swift rename to Sources/AnthropicSwiftSDK/Network/Response/StreamingResponse.swift diff --git a/Tests/AnthropicSwiftSDKTests/MessageBatchesTests.swift b/Tests/AnthropicSwiftSDKTests/MessageBatchesTests.swift new file mode 100644 index 0000000..0b798b6 --- /dev/null +++ b/Tests/AnthropicSwiftSDKTests/MessageBatchesTests.swift @@ -0,0 +1,243 @@ +// +// MessageBatchesTests.swift +// AnthropicSwiftSDK +// +// Created by 伊藤史 on 2024/10/18. +// + +import XCTest +import AnthropicSwiftSDK_TestUtils +@testable import AnthropicSwiftSDK + +final class MessageBatchesTests: XCTestCase { + var session = URLSession.shared + + override func setUp() { + let configuration = URLSessionConfiguration.default + configuration.protocolClasses = [HTTPMock.self] + + session = URLSession(configuration: configuration) + } + + func testMessageBatchesUsesProvidedAPIKey() async throws { + let batches = MessageBatches(apiKey: "This-is-test-API-key", session: session) + let expectation = XCTestExpectation(description: "MessageBatches uses provided api key in request header.") + + HTTPMock.inspectType = .requestHeader({ headers in + XCTAssertEqual(headers!["x-api-key"], "This-is-test-API-key") + expectation.fulfill() + }, + """ + { + "id": "batch_123456", + "type": "message_batch", + "processing_status": "ended", + "request_counts": { + "processing": 0, + "succeeeded": 95, + "errored": 3, + "canceled": 1, + "expired": 1 + }, + "ended_at": "2024-10-18T15:30:00Z", + "created_at": "2024-10-18T15:00:00Z", + "expires_at": "2024-10-19T15:00:00Z", + "cancel_initiated_at": null, + "results_url": "https://example.com/results/batch_123456.jsonl" + } + """) + + let batch = MessageBatch( + customId: "This-is-custom-id", + parameter: .init( + messages: [ + .init( + role: .user, + content: [ + .text("This is test text") + ] + ) + ], + maxTokens: 1024 + ) + ) + let _ = try await batches.createBatches(batches: [batch]) + + await fulfillment(of: [expectation], timeout: 1.0) + } + + func testCreateMessageBatchesReturnsBatchResponse() async throws { + let batches = MessageBatches(apiKey: "This-is-test-API-key", session: session) + + HTTPMock.inspectType = .response( + """ + { + "id": "batch_123456", + "type": "message_batch", + "processing_status": "ended", + "request_counts": { + "processing": 0, + "succeeeded": 95, + "errored": 3, + "canceled": 1, + "expired": 1 + }, + "ended_at": "2024-10-18T15:30:00Z", + "created_at": "2024-10-18T15:00:00Z", + "expires_at": "2024-10-19T15:00:00Z", + "cancel_initiated_at": null, + "results_url": "https://example.com/results/batch_123456.jsonl" + } + """) + + let batch = MessageBatch( + customId: "This-is-custom-id", + parameter: .init(messages: [.init(role: .user, content: [.text("This is test text")])], maxTokens: 1024) + ) + let response = try await batches.createBatches(batches: [batch]) + XCTAssertEqual(response.id, "batch_123456") + XCTAssertEqual(response.type, .message) + XCTAssertEqual(response.processingStatus, .ended) + XCTAssertEqual(response.requestCounts.processing, 0) + XCTAssertEqual(response.requestCounts.succeeeded, 95) + XCTAssertEqual(response.requestCounts.errored, 3) + XCTAssertEqual(response.requestCounts.canceled, 1) + XCTAssertEqual(response.requestCounts.expired, 1) + XCTAssertEqual(response.endedAt, "2024-10-18T15:30:00Z") + XCTAssertEqual(response.createdAt, "2024-10-18T15:00:00Z") + XCTAssertEqual(response.expiresAt, "2024-10-19T15:00:00Z") + XCTAssertNil(response.cancelInitiatedAt) + XCTAssertEqual(response.resultsUrl, "https://example.com/results/batch_123456.jsonl") + } + + func testGetBatch() async throws { + let batches = MessageBatches(apiKey: "This-is-test-API-key", session: session) + + HTTPMock.inspectType = .response(""" + { + "id": "batch_123456", + "type": "message_batch", + "processing_status": "in_progress", + "request_counts": { + "processing": 50, + "succeeeded": 0, + "errored": 0, + "canceled": 0, + "expired": 0 + }, + "created_at": "2024-10-18T15:00:00Z", + "expires_at": "2024-10-19T15:00:00Z", + "cancel_initiated_at": null, + "results_url": null + } + """) + + let response = try await batches.retrieve(batchId: "batch_123456") + XCTAssertEqual(response.id, "batch_123456") + XCTAssertEqual(response.type, .message) + XCTAssertEqual(response.processingStatus, .inProgress) + XCTAssertEqual(response.requestCounts.processing, 50) + XCTAssertEqual(response.createdAt, "2024-10-18T15:00:00Z") + XCTAssertEqual(response.expiresAt, "2024-10-19T15:00:00Z") + XCTAssertNil(response.cancelInitiatedAt) + XCTAssertNil(response.resultsUrl) + } + + func testCancelBatch() async throws { + let batches = MessageBatches(apiKey: "This-is-test-API-key", session: session) + + HTTPMock.inspectType = .response(""" + { + "id": "batch_123456", + "type": "message_batch", + "processing_status": "canceling", + "request_counts": { + "processing": 25, + "succeeeded": 25, + "errored": 0, + "canceled": 0, + "expired": 0 + }, + "created_at": "2024-10-18T15:00:00Z", + "expires_at": "2024-10-19T15:00:00Z", + "cancel_initiated_at": "2024-10-18T15:30:00Z", + "results_url": null + } + """) + + let response = try await batches.cancel(batchId: "batch_123456") + XCTAssertEqual(response.id, "batch_123456") + XCTAssertEqual(response.processingStatus, .canceling) + XCTAssertEqual(response.cancelInitiatedAt, "2024-10-18T15:30:00Z") + } + + func testGetBatchResults() async throws { + let batches = MessageBatches(apiKey: "This-is-test-API-key", session: session) + + HTTPMock.inspectType = .response(""" + {"custom_id":"request_123","result":{"type":"succeeded","message":{"id":"msg_123456","type":"message","role":"assistant","model":"claude-3-opus-20240229","content":[{"type":"text","text":"Hello!"}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":11,"output_tokens":1}}}} + {"custom_id":"request_456","result":{"type":"errored","error":{"type":"invalid_request_error","message":"Invalid input"}}} + """) + + let results = try await batches.results(of: "batch_123456") + XCTAssertEqual(results.count, 2) + XCTAssertEqual(results[0].customId, "request_123") + XCTAssertEqual(results[0].result?.type, .succeeded) + XCTAssertEqual(results[1].customId, "request_456") + XCTAssertEqual(results[1].result?.type, .errored) + } + + func testCreateMultipleBatches() async throws { + let batches = MessageBatches(apiKey: "This-is-test-API-key", session: session) + + HTTPMock.inspectType = .response(""" + { + "id": "batch_789012", + "type": "message_batch", + "processing_status": "in_progress", + "request_counts": { + "processing": 100, + "succeeeded": 0, + "errored": 0, + "canceled": 0, + "expired": 0 + }, + "created_at": "2024-10-18T16:00:00Z", + "expires_at": "2024-10-19T16:00:00Z", + "cancel_initiated_at": null, + "results_url": null + } + """) + + let batch1 = MessageBatch(customId: "custom_id_1", parameter: .init(messages: [.init(role: .user, content: [.text("Test 1")])], maxTokens: 1024)) + let batch2 = MessageBatch(customId: "custom_id_2", parameter: .init(messages: [.init(role: .user, content: [.text("Test 2")])], maxTokens: 1024)) + let response = try await batches.createBatches(batches: [batch1, batch2]) + + XCTAssertEqual(response.id, "batch_789012") + XCTAssertEqual(response.processingStatus, .inProgress) + XCTAssertEqual(response.requestCounts.processing, 100) + } + + func testCreateBatchesWithInvalidAPIKey() async throws { + let batches = MessageBatches(apiKey: "Invalid-API-Key", session: session) + + HTTPMock.inspectType = .error(""" + { + "type": "error", + "error": { + "type": "invalid_request_error", + "message": "There was an issue with the format or content of your request." + } + } + """) + + let batch = MessageBatch(customId: "custom_id", parameter: .init(messages: [.init(role: .user, content: [.text("Test")])], maxTokens: 1024)) + + do { + let _ = try await batches.createBatches(batches: [batch]) + XCTFail("Expected an error to be thrown") + } catch let error as AnthropicAPIError { + XCTAssertEqual(error, .invalidRequestError) + } + } +} diff --git a/Tests/AnthropicSwiftSDKTests/MessagesTests.swift b/Tests/AnthropicSwiftSDKTests/MessagesTests.swift index 2809022..4ee026c 100644 --- a/Tests/AnthropicSwiftSDKTests/MessagesTests.swift +++ b/Tests/AnthropicSwiftSDKTests/MessagesTests.swift @@ -26,7 +26,26 @@ final class MessagesTests: XCTestCase { HTTPMock.inspectType = .requestHeader({ headers in XCTAssertEqual(headers!["x-api-key"], "This-is-test-API-key") expectation.fulfill() - }) + }, """ + { + "id": "msg_01XFDUDYJgAACzvnptvVoYEL", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "text", + "text": "Hello!" + } + ], + "model": "claude-2.1", + "stop_reason": "end_turn", + "stop_sequence": null, + "usage": { + "input_tokens": 12, + "output_tokens": 6 + } + } + """) let message = Message(role: .user, content: [.text("This is test text")]) let _ = try await messages.createMessage([message], maxTokens: 1024) diff --git a/Tests/AnthropicSwiftSDKTests/Network/AnthropicAPIClientTests.swift b/Tests/AnthropicSwiftSDKTests/Network/AnthropicAPIClientTests.swift index 7889d81..9b20968 100644 --- a/Tests/AnthropicSwiftSDKTests/Network/AnthropicAPIClientTests.swift +++ b/Tests/AnthropicSwiftSDKTests/Network/AnthropicAPIClientTests.swift @@ -20,10 +20,10 @@ final class AnthropicAPIClientTests: XCTestCase { } func testAPITypeProvidesCorrectMethodAndPathForSend() async throws { - let client = AnthropicAPIClient( + let client = APIClient( + session: session, anthropicHeaderProvider: DefaultAnthropicHeaderProvider(), - authenticationHeaderProvider: APIKeyAuthenticationHeaderProvider(apiKey: ""), - session: session + authenticationHeaderProvider: APIKeyAuthenticationHeaderProvider(apiKey: "") ) let expectation = XCTestExpectation(description: "APIType.message should `/v1/messages` path and `POST` method.") @@ -32,17 +32,17 @@ final class AnthropicAPIClientTests: XCTestCase { XCTAssertEqual(request.httpMethod, "POST") expectation.fulfill() - }) + }, nil) - let _ = try await client.send(requestBody: .nop) + let _ = try await client.send(request: NopRequest()) await fulfillment(of: [expectation], timeout: 1.0) } func testAPITypeProvidesCorrectMethodAndPathForStream() async throws { - let client = AnthropicAPIClient( + let client = APIClient( + session: session, anthropicHeaderProvider: DefaultAnthropicHeaderProvider(), - authenticationHeaderProvider: APIKeyAuthenticationHeaderProvider(apiKey: ""), - session: session + authenticationHeaderProvider: APIKeyAuthenticationHeaderProvider(apiKey: "") ) let expectation = XCTestExpectation(description: "APIType.message should `/v1/messages` path and `POST` method.") @@ -51,17 +51,17 @@ final class AnthropicAPIClientTests: XCTestCase { XCTAssertEqual(request.httpMethod, "POST") expectation.fulfill() - }) + }, nil) - let _ = try await client.stream(requestBody: .nop) + let _ = try await client.stream(request: NopRequest()) await fulfillment(of: [expectation], timeout: 1.0) } func testHeaderProviderProvidesCorrectHeadersForSend() async throws { - let client = AnthropicAPIClient( + let client = APIClient( + session: session, anthropicHeaderProvider: DefaultAnthropicHeaderProvider(), - authenticationHeaderProvider: APIKeyAuthenticationHeaderProvider(apiKey: "test-api-key"), - session: session + authenticationHeaderProvider: APIKeyAuthenticationHeaderProvider(apiKey: "test-api-key") ) let expectation = XCTestExpectation(description: "API request should have headers `x-api-key`, `anthropic-version`, `content-type` and `anthropic-beta`.") @@ -69,20 +69,20 @@ final class AnthropicAPIClientTests: XCTestCase { XCTAssertEqual(headers!["x-api-key"], "test-api-key") XCTAssertEqual(headers!["anthropic-version"], "2023-06-01") XCTAssertEqual(headers!["Content-Type"], "application/json") - XCTAssertEqual(headers!["anthropic-beta"], "prompt-caching-2024-07-31") + XCTAssertEqual(headers!["anthropic-beta"], "message-batches-2024-09-24") expectation.fulfill() - }) + }, nil) - let _ = try await client.send(requestBody: .nop) + let _ = try await client.send(request: NopRequest()) await fulfillment(of: [expectation], timeout: 1.0) } func testHeaderProviderProvidesCorrectHeadersForStream() async throws { - let client = AnthropicAPIClient( + let client = APIClient( + session: session, anthropicHeaderProvider: DefaultAnthropicHeaderProvider(), - authenticationHeaderProvider: APIKeyAuthenticationHeaderProvider(apiKey: "test-api-key"), - session: session + authenticationHeaderProvider: APIKeyAuthenticationHeaderProvider(apiKey: "test-api-key") ) let expectation = XCTestExpectation(description: "API request should have headers `x-api-key`, `anthropic-version`, `content-type` and `anthropic-beta`.") @@ -90,12 +90,12 @@ final class AnthropicAPIClientTests: XCTestCase { XCTAssertEqual(headers!["x-api-key"], "test-api-key") XCTAssertEqual(headers!["anthropic-version"], "2023-06-01") XCTAssertEqual(headers!["Content-Type"], "application/json") - XCTAssertEqual(headers!["anthropic-beta"], "prompt-caching-2024-07-31") + XCTAssertEqual(headers!["anthropic-beta"], "message-batches-2024-09-24") expectation.fulfill() - }) + }, nil) - let _ = try await client.stream(requestBody: .nop) + let _ = try await client.stream(request: NopRequest()) await fulfillment(of: [expectation], timeout: 1.0) } diff --git a/Tests/AnthropicSwiftSDKTests/Network/BatchListResponseTests.swift b/Tests/AnthropicSwiftSDKTests/Network/BatchListResponseTests.swift new file mode 100644 index 0000000..3d024f4 --- /dev/null +++ b/Tests/AnthropicSwiftSDKTests/Network/BatchListResponseTests.swift @@ -0,0 +1,99 @@ +// +// BatchListResponseTests.swift +// AnthropicSwiftSDK +// +// Created by 伊藤史 on 2024/10/18. +// + +import XCTest +@testable import AnthropicSwiftSDK + +final class BatchListResponseTests: XCTestCase { + func testDecodeBatchListResponse() throws { + let json = """ + { + "data": [ + { + "id": "batch_123", + "type": "message_batch", + "processing_status": "ended", + "request_counts": { + "processing": 0, + "succeeeded": 95, + "errored": 5, + "canceled": 0, + "expired": 0 + }, + "ended_at": "2024-10-18T12:05:00Z", + "created_at": "2024-10-18T12:00:00Z", + "expires_at": "2024-10-19T12:00:00Z", + "cancel_initiated_at": null, + "results_url": "https://example.com/results/batch_123.jsonl" + }, + { + "id": "batch_456", + "type": "message_batch", + "processing_status": "in_progress", + "request_counts": { + "processing": 100, + "succeeeded": 0, + "errored": 0, + "canceled": 0, + "expired": 0 + }, + "ended_at": null, + "created_at": "2024-10-18T12:10:00Z", + "expires_at": "2024-10-19T12:10:00Z", + "cancel_initiated_at": null, + "results_url": null + } + ], + "has_more": true, + "first_id": "batch_123", + "last_id": "batch_456" + } + """ + + let jsonData = json.data(using: .utf8)! + + let response = try anthropicJSONDecoder.decode(BatchListResponse.self, from: jsonData) + + XCTAssertEqual(response.data.count, 2) + + // Test first batch + let firstBatch = response.data[0] + XCTAssertEqual(firstBatch.id, "batch_123") + XCTAssertEqual(firstBatch.type, .message) + XCTAssertEqual(firstBatch.processingStatus, .ended) + XCTAssertEqual(firstBatch.requestCounts.processing, 0) + XCTAssertEqual(firstBatch.requestCounts.succeeeded, 95) + XCTAssertEqual(firstBatch.requestCounts.errored, 5) + XCTAssertEqual(firstBatch.requestCounts.canceled, 0) + XCTAssertEqual(firstBatch.requestCounts.expired, 0) + XCTAssertEqual(firstBatch.endedAt, "2024-10-18T12:05:00Z") + XCTAssertEqual(firstBatch.createdAt, "2024-10-18T12:00:00Z") + XCTAssertEqual(firstBatch.expiresAt, "2024-10-19T12:00:00Z") + XCTAssertNil(firstBatch.cancelInitiatedAt) + XCTAssertEqual(firstBatch.resultsUrl, "https://example.com/results/batch_123.jsonl") + + // Test second batch + let secondBatch = response.data[1] + XCTAssertEqual(secondBatch.id, "batch_456") + XCTAssertEqual(secondBatch.type, .message) + XCTAssertEqual(secondBatch.processingStatus, .inProgress) + XCTAssertEqual(secondBatch.requestCounts.processing, 100) + XCTAssertEqual(secondBatch.requestCounts.succeeeded, 0) + XCTAssertEqual(secondBatch.requestCounts.errored, 0) + XCTAssertEqual(secondBatch.requestCounts.canceled, 0) + XCTAssertEqual(secondBatch.requestCounts.expired, 0) + XCTAssertNil(secondBatch.endedAt) + XCTAssertEqual(secondBatch.createdAt, "2024-10-18T12:10:00Z") + XCTAssertEqual(secondBatch.expiresAt, "2024-10-19T12:10:00Z") + XCTAssertNil(secondBatch.cancelInitiatedAt) + XCTAssertNil(secondBatch.resultsUrl) + + XCTAssertTrue(response.hasMore) + XCTAssertEqual(response.firstId, "batch_123") + XCTAssertEqual(response.lastId, "batch_456") + } +} \ No newline at end of file diff --git a/Tests/AnthropicSwiftSDKTests/Network/BatchResponseTests.swift b/Tests/AnthropicSwiftSDKTests/Network/BatchResponseTests.swift new file mode 100644 index 0000000..49dcb79 --- /dev/null +++ b/Tests/AnthropicSwiftSDKTests/Network/BatchResponseTests.swift @@ -0,0 +1,99 @@ +// +// BatchResponseTests.swift +// AnthropicSwiftSDK +// +// Created by 伊藤史 on 2024/10/18. +// + +import XCTest +@testable import AnthropicSwiftSDK + +final class BatchResponseTests: XCTestCase { + func testDecodeBatchResponse() throws { + let json = """ + { + "id": "batch_123456", + "type": "message_batch", + "processing_status": "ended", + "request_counts": { + "processing": 0, + "succeeeded": 95, + "errored": 3, + "canceled": 1, + "expired": 1 + }, + "ended_at": "2024-10-18T15:30:00Z", + "created_at": "2024-10-18T15:00:00Z", + "expires_at": "2024-10-19T15:00:00Z", + "cancel_initiated_at": null, + "results_url": "https://example.com/results/batch_123456.jsonl" + } + """ + + let jsonData = json.data(using: .utf8)! + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + + let batchResponse = try decoder.decode(BatchResponse.self, from: jsonData) + + XCTAssertEqual(batchResponse.id, "batch_123456") + XCTAssertEqual(batchResponse.type, .message) + XCTAssertEqual(batchResponse.processingStatus, .ended) + + XCTAssertEqual(batchResponse.requestCounts.processing, 0) + XCTAssertEqual(batchResponse.requestCounts.succeeeded, 95) + XCTAssertEqual(batchResponse.requestCounts.errored, 3) + XCTAssertEqual(batchResponse.requestCounts.canceled, 1) + XCTAssertEqual(batchResponse.requestCounts.expired, 1) + + XCTAssertEqual(batchResponse.endedAt, "2024-10-18T15:30:00Z") + XCTAssertEqual(batchResponse.createdAt, "2024-10-18T15:00:00Z") + XCTAssertEqual(batchResponse.expiresAt, "2024-10-19T15:00:00Z") + XCTAssertNil(batchResponse.cancelInitiatedAt) + XCTAssertEqual(batchResponse.resultsUrl, "https://example.com/results/batch_123456.jsonl") + } + + func testDecodeBatchResponseWithCancellation() throws { + let json = """ + { + "id": "batch_789012", + "type": "message_batch", + "processing_status": "canceling", + "request_counts": { + "processing": 50, + "succeeeded": 40, + "errored": 10, + "canceled": 0, + "expired": 0 + }, + "ended_at": null, + "created_at": "2024-10-18T16:00:00Z", + "expires_at": "2024-10-19T16:00:00Z", + "cancel_initiated_at": "2024-10-18T16:30:00Z", + "results_url": null + } + """ + + let jsonData = json.data(using: .utf8)! + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + + let batchResponse = try decoder.decode(BatchResponse.self, from: jsonData) + + XCTAssertEqual(batchResponse.id, "batch_789012") + XCTAssertEqual(batchResponse.type, .message) + XCTAssertEqual(batchResponse.processingStatus, .canceling) + + XCTAssertEqual(batchResponse.requestCounts.processing, 50) + XCTAssertEqual(batchResponse.requestCounts.succeeeded, 40) + XCTAssertEqual(batchResponse.requestCounts.errored, 10) + XCTAssertEqual(batchResponse.requestCounts.canceled, 0) + XCTAssertEqual(batchResponse.requestCounts.expired, 0) + + XCTAssertNil(batchResponse.endedAt) + XCTAssertEqual(batchResponse.createdAt, "2024-10-18T16:00:00Z") + XCTAssertEqual(batchResponse.expiresAt, "2024-10-19T16:00:00Z") + XCTAssertEqual(batchResponse.cancelInitiatedAt, "2024-10-18T16:30:00Z") + XCTAssertNil(batchResponse.resultsUrl) + } +} \ No newline at end of file diff --git a/Tests/AnthropicSwiftSDKTests/Network/BatchResultResponseTests.swift b/Tests/AnthropicSwiftSDKTests/Network/BatchResultResponseTests.swift new file mode 100644 index 0000000..682d2df --- /dev/null +++ b/Tests/AnthropicSwiftSDKTests/Network/BatchResultResponseTests.swift @@ -0,0 +1,89 @@ +// +// BatchResultResponseTests.swift +// AnthropicSwiftSDK +// +// Created by 伊藤史 on 2024/10/18. +// + +import XCTest +@testable import AnthropicSwiftSDK + +final class BatchResultResponseTests: XCTestCase { + func testDecodeBatchResultResponseSucceeded() throws { + let json = """ + {"custom_id":"request_123","result":{"type":"succeeded","message":{"id":"msg_123456","type":"message","role":"assistant","model":"claude-3-5-sonnet-20240620","content":[{"type":"text","text":"Hello again! It's nice to see you. How can I assist you today? Is there anything specific you'd like to chat about or any questions you have?"}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":11,"output_tokens":36}}}} + """ + + let jsonData = json.data(using: .utf8)! + let batchResultResponse = try anthropicJSONDecoder.decode(BatchResultResponse.self, from: jsonData) + + XCTAssertEqual(batchResultResponse.customId, "request_123") + XCTAssertNotNil(batchResultResponse.result) + XCTAssertNil(batchResultResponse.result?.error) + + XCTAssertEqual(batchResultResponse.result?.type, .succeeded) + XCTAssertEqual(batchResultResponse.result?.message?.id, "msg_123456") + XCTAssertEqual(batchResultResponse.result?.message?.type, .message) + XCTAssertEqual(batchResultResponse.result?.message?.role, .assistant) + guard case let .text(text1) = batchResultResponse.result?.message?.content.first else { + XCTFail("batchResultResponse.result?.message.content.first is not .text") + return + } + XCTAssertEqual(text1, "Hello again! It's nice to see you. How can I assist you today? Is there anything specific you'd like to chat about or any questions you have?") + XCTAssertEqual(batchResultResponse.result?.message?.model?.stringfy, Model.claude_3_5_Sonnet.stringfy) + XCTAssertEqual(batchResultResponse.result?.message?.stopReason, .endTurn) + XCTAssertNil(batchResultResponse.result?.message?.stopSequence) + XCTAssertEqual(batchResultResponse.result?.message?.usage.inputTokens, 11) + XCTAssertEqual(batchResultResponse.result?.message?.usage.outputTokens, 36) + } + + func testDecodeBatchResultResponseErrored() throws { + let json = """ + { + "custom_id": "request_456", + "result": { + "type": "errored", + "error": { + "type": "invalid_request_error", + "message": "Invalid input: The provided text contains inappropriate content." + } + } + } + """ + + let jsonData = json.data(using: .utf8)! + let batchResultResponse = try anthropicJSONDecoder.decode(BatchResultResponse.self, from: jsonData) + + XCTAssertEqual(batchResultResponse.customId, "request_456") + XCTAssertNotNil(batchResultResponse.result) + XCTAssertNotNil(batchResultResponse.result?.error) + + XCTAssertEqual(batchResultResponse.result?.type, .errored) + XCTAssertNil(batchResultResponse.result?.message) + + XCTAssertEqual(batchResultResponse.result?.error?.type, .invalidRequestError) + XCTAssertEqual(batchResultResponse.result?.error?.message, "Invalid input: The provided text contains inappropriate content.") + } + + func testDecodeBatchResultResponseCancelled() throws { + let json = """ + { + "custom_id": "request_789", + "result": { + "type": "cancelled", + "error": null + } + } + """ + + let jsonData = json.data(using: .utf8)! + let batchResultResponse = try anthropicJSONDecoder.decode(BatchResultResponse.self, from: jsonData) + + XCTAssertEqual(batchResultResponse.customId, "request_789") + XCTAssertNotNil(batchResultResponse.result) + XCTAssertNil(batchResultResponse.result?.error) + + XCTAssertEqual(batchResultResponse.result?.type, .cancelled) + XCTAssertNil(batchResultResponse.result?.message) + } +} diff --git a/Tests/AnthropicSwiftSDKTests/Network/HeaderProvider/DefaultAnthropicHeaderProviderTests.swift b/Tests/AnthropicSwiftSDKTests/Network/HeaderProvider/DefaultAnthropicHeaderProviderTests.swift index 8456c37..d682373 100644 --- a/Tests/AnthropicSwiftSDKTests/Network/HeaderProvider/DefaultAnthropicHeaderProviderTests.swift +++ b/Tests/AnthropicSwiftSDKTests/Network/HeaderProvider/DefaultAnthropicHeaderProviderTests.swift @@ -29,7 +29,7 @@ final class DefaultAnthropicHeaderProviderTests: XCTestCase { let provider = DefaultAnthropicHeaderProvider(useBeta: true) let headers = provider.getAnthropicAPIHeaders() - XCTAssertEqual(headers["anthropic-beta"], "prompt-caching-2024-07-31") + XCTAssertEqual(headers["anthropic-beta"], "message-batches-2024-09-24") } func testBetaHeaderShouldNotBeProvidedIfUseBeta() { diff --git a/Tests/AnthropicSwiftSDKTests/Network/Request/CancelMessageBatchRequestTests.swift b/Tests/AnthropicSwiftSDKTests/Network/Request/CancelMessageBatchRequestTests.swift new file mode 100644 index 0000000..66c1579 --- /dev/null +++ b/Tests/AnthropicSwiftSDKTests/Network/Request/CancelMessageBatchRequestTests.swift @@ -0,0 +1,24 @@ +// +// CancelMessageBatchRequestTests.swift +// AnthropicSwiftSDK +// +// Created by 伊藤史 on 2024/10/23. +// + +import XCTest +@testable import AnthropicSwiftSDK + +final class CancelMessageBatchRequestTests: XCTestCase { + + func testCancelMessageBatchRequest() { + let testBatchId = "test_batch_123" + + let request = CancelMessageBatchRequest(batchId: testBatchId) + + XCTAssertEqual(request.method, .post) + XCTAssertEqual(request.path, "\(RequestType.batches.basePath)/\(testBatchId)/cancel") + XCTAssertNil(request.queries) + XCTAssertNil(request.body) + XCTAssertEqual(request.batchId, testBatchId) + } +} diff --git a/Tests/AnthropicSwiftSDKTests/Network/Request/ListMessageBatchesRequestTests.swift b/Tests/AnthropicSwiftSDKTests/Network/Request/ListMessageBatchesRequestTests.swift new file mode 100644 index 0000000..e0f8018 --- /dev/null +++ b/Tests/AnthropicSwiftSDKTests/Network/Request/ListMessageBatchesRequestTests.swift @@ -0,0 +1,34 @@ +// +// ListMessageBatchesRequestTests.swift +// AnthropicSwiftSDK +// +// Created by 伊藤史 on 2024/10/23. +// + +import XCTest +@testable import AnthropicSwiftSDK + +final class ListMessageBatchesRequestTests: XCTestCase { + + func testListMessageBatchesRequestProperties() { + let queries: [String: CustomStringConvertible] = [ + "before_id": "batch123", + "after_id": "batch456", + "limit": 10 + ] + let request = ListMessageBatchesRequest(queries: queries) + + XCTAssertEqual(request.method, .get) + XCTAssertEqual(request.path, RequestType.batches.basePath) + XCTAssertEqual(request.queries?["before_id"] as? String, "batch123") + XCTAssertEqual(request.queries?["after_id"] as? String, "batch456") + XCTAssertEqual(request.queries?["limit"] as? Int, 10) + XCTAssertNil(request.body) + } + + func testParameterRawValues() { + XCTAssertEqual(ListMessageBatchesRequest.Parameter.beforeId.rawValue, "before_id") + XCTAssertEqual(ListMessageBatchesRequest.Parameter.afterId.rawValue, "after_id") + XCTAssertEqual(ListMessageBatchesRequest.Parameter.limit.rawValue, "limit") + } +} diff --git a/Tests/AnthropicSwiftSDKTests/Network/Request/MessageBatchesRequestTests.swift b/Tests/AnthropicSwiftSDKTests/Network/Request/MessageBatchesRequestTests.swift new file mode 100644 index 0000000..e4ff3fe --- /dev/null +++ b/Tests/AnthropicSwiftSDKTests/Network/Request/MessageBatchesRequestTests.swift @@ -0,0 +1,61 @@ +// +// MessageBatchesRequestTests.swift +// AnthropicSwiftSDK +// +// Created by 伊藤史 on 2024/10/22. +// + +import XCTest +@testable import AnthropicSwiftSDK + +final class MessageBatchesRequestTests: XCTestCase { + + func testMessageBatchesRequest() throws { + let batchParameter1 = BatchParameter( + messages: [Message(role: .user, content: [.text("こんにちは")])], + model: .claude_3_Opus, + maxTokens: 100 + ) + let batchParameter2 = BatchParameter( + messages: [Message(role: .user, content: [.text("お元気ですか?")])], + model: .claude_3_Sonnet, + maxTokens: 200 + ) + + let messageBatch1 = MessageBatch(customId: "test1", parameter: batchParameter1) + let messageBatch2 = MessageBatch(customId: "test2", parameter: batchParameter2) + + let request = MessageBatchesRequest(body: .init(from: [messageBatch1, messageBatch2])) + + XCTAssertEqual(request.method, .post) + XCTAssertEqual(request.path, RequestType.batches.basePath) + XCTAssertNil(request.queries) + XCTAssertNotNil(request.body) + + XCTAssertEqual(request.body?.requests.count, 2) + + XCTAssertEqual(request.body?.requests[0].customId, "test1") + XCTAssertEqual(request.body?.requests[0].params.model.stringfy, Model.claude_3_Opus.stringfy) + XCTAssertEqual(request.body?.requests[0].params.maxTokens, 100) + XCTAssertEqual(request.body?.requests[0].params.messages.count, 1) + XCTAssertEqual(request.body?.requests[0].params.messages[0].role.rawValue, "user") + let content1 = try XCTUnwrap(request.body?.requests[0].params.messages[0].content) + guard case let .text(text1) = content1[0] else { + XCTFail("content1[0] is not .text") + return + } + XCTAssertEqual(text1, "こんにちは") + + XCTAssertEqual(request.body?.requests[1].customId, "test2") + XCTAssertEqual(request.body?.requests[1].params.model.stringfy, Model.claude_3_Sonnet.stringfy) + XCTAssertEqual(request.body?.requests[1].params.maxTokens, 200) + XCTAssertEqual(request.body?.requests[1].params.messages.count, 1) + XCTAssertEqual(request.body?.requests[1].params.messages[0].role.rawValue, "user") + let content2 = try XCTUnwrap(request.body?.requests[1].params.messages[0].content) + guard case let .text(text2) = content2[0] else { + XCTFail("content2[0] is not .text") + return + } + XCTAssertEqual(text2, "お元気ですか?") + } +} \ No newline at end of file diff --git a/Tests/AnthropicSwiftSDKTests/Network/Request/MessagesRequestTests.swift b/Tests/AnthropicSwiftSDKTests/Network/Request/MessagesRequestTests.swift new file mode 100644 index 0000000..7e1e613 --- /dev/null +++ b/Tests/AnthropicSwiftSDKTests/Network/Request/MessagesRequestTests.swift @@ -0,0 +1,109 @@ +// +// MessagesRequestTests.swift +// AnthropicSwiftSDK +// +// Created by 伊藤史 on 2024/10/22. +// + +import XCTest +@testable import AnthropicSwiftSDK + +final class MessagesRequestTests: XCTestCase { + func testMessagesRequest() throws { + let messages = [Message(role: .user, content: [.text("こんにちは")])] + let systemPrompt: [SystemPrompt] = [.text("あなたは親切なアシスタントです。", .ephemeral)] + + let requestBody = MessagesRequestBody( + model: .claude_3_Opus, + messages: messages, + system: systemPrompt, + maxTokens: 100, + metaData: MetaData(userId: "test-user"), + stopSequences: ["END"], + stream: false, + temperature: 0.7, + topP: 0.9, + topK: 10, + tools: nil, + toolChoice: .auto + ) + + let request = MessagesRequest(body: requestBody) + + XCTAssertEqual(request.method, .post) + XCTAssertEqual(request.path, RequestType.messages.basePath) + XCTAssertNil(request.queries) + + XCTAssertEqual(request.body?.model.stringfy, Model.claude_3_Opus.stringfy) + XCTAssertEqual(request.body?.messages.count, 1) + XCTAssertEqual(request.body?.messages[0].role.rawValue, "user") + let content1 = try XCTUnwrap(request.body?.messages[0].content) + guard case let .text(text1) = content1[0] else { + XCTFail("content1[0] is not .text") + return + } + XCTAssertEqual(text1, "こんにちは") + let system1 = try XCTUnwrap(request.body?.system) + guard case let .text(text1, _) = system1[0] else { + XCTFail("system1[0] is not .text") + return + } + XCTAssertEqual(text1, "あなたは親切なアシスタントです。") + XCTAssertEqual(request.body?.maxTokens, 100) + XCTAssertEqual(request.body?.metaData?.userId, "test-user") + XCTAssertEqual(request.body?.stopSequences, ["END"]) + XCTAssertFalse(request.body?.stream ?? true) + XCTAssertEqual(request.body?.temperature, 0.7) + XCTAssertEqual(request.body?.topP, 0.9) + XCTAssertEqual(request.body?.topK, 10) + XCTAssertNil(request.body?.tools) + XCTAssertNil(request.body?.toolChoice) + } + + func testMessagesRequestWithBatchParameter() throws { + let messages = [Message(role: .user, content: [.text("こんにちは")])] + let systemPrompt: [SystemPrompt] = [.text("あなたは親切なアシスタントです。", .ephemeral)] + + let batchParameter = BatchParameter( + messages: messages, + model: .claude_3_Opus, + system: systemPrompt, + maxTokens: 100, + metaData: MetaData(userId: "test-user"), + stopSequence: ["END"], + temperature: 0.7, + topP: 0.9, + topK: 10, + toolContainer: nil, + toolChoice: .auto + ) + + let requestBody = MessagesRequestBody(from: batchParameter) + let request = MessagesRequest(body: requestBody) + + XCTAssertEqual(request.body?.model.stringfy, Model.claude_3_Opus.stringfy) + XCTAssertEqual(request.body?.messages.count, 1) + XCTAssertEqual(request.body?.messages[0].role.rawValue, "user") + let content1 = try XCTUnwrap(request.body?.messages[0].content) + guard case let .text(text1) = content1[0] else { + XCTFail("content1[0] is not .text") + return + } + XCTAssertEqual(text1, "こんにちは") + let system1 = try XCTUnwrap(request.body?.system) + guard case let .text(text1, _) = system1[0] else { + XCTFail("system1[0] is not .text") + return + } + XCTAssertEqual(text1, "あなたは親切なアシスタントです。") + XCTAssertEqual(request.body?.maxTokens, 100) + XCTAssertEqual(request.body?.metaData?.userId, "test-user") + XCTAssertEqual(request.body?.stopSequences, ["END"]) + XCTAssertFalse(request.body?.stream ?? true) + XCTAssertEqual(request.body?.temperature, 0.7) + XCTAssertEqual(request.body?.topP, 0.9) + XCTAssertEqual(request.body?.topK, 10) + XCTAssertNil(request.body?.tools) + XCTAssertNil(request.body?.toolChoice) + } +} \ No newline at end of file diff --git a/Tests/AnthropicSwiftSDKTests/Network/Request/RequestTests.swift b/Tests/AnthropicSwiftSDKTests/Network/Request/RequestTests.swift new file mode 100644 index 0000000..bbc489b --- /dev/null +++ b/Tests/AnthropicSwiftSDKTests/Network/Request/RequestTests.swift @@ -0,0 +1,63 @@ +// +// RequestTests.swift +// AnthropicSwiftSDK +// +// Created by 伊藤史 on 2024/10/23. +// + +import XCTest +@testable import AnthropicSwiftSDK + +class RequestTests: XCTestCase { + + struct MockRequest: Request { + typealias Body = [String: String] + + var method: HttpMethod = .post + var path: String = "/test" + var queries: [String: CustomStringConvertible]? = ["key": "value"] + var body: Body? = ["test": "data"] + } + + func testToURLRequest() throws { + let baseURL = URL(string: "https://api.anthropic.com")! + let mockRequest = MockRequest() + + let urlRequest = try mockRequest.toURLRequest(for: baseURL) + + XCTAssertEqual(urlRequest.url?.absoluteString, "https://api.anthropic.com/test?key=value") + XCTAssertEqual(urlRequest.httpMethod, "POST") + + let bodyData = try XCTUnwrap(urlRequest.httpBody) + let decodedBody = try JSONDecoder().decode([String: String].self, from: bodyData) + XCTAssertEqual(decodedBody, ["test": "data"]) + } + + func testToURLRequestWithoutBody() throws { + let baseURL = URL(string: "https://api.anthropic.com")! + var mockRequest = MockRequest() + mockRequest.body = nil + mockRequest.method = .get + + let urlRequest = try mockRequest.toURLRequest(for: baseURL) + + XCTAssertEqual(urlRequest.url?.absoluteString, "https://api.anthropic.com/test?key=value") + XCTAssertEqual(urlRequest.httpMethod, "GET") + XCTAssertNil(urlRequest.httpBody) + } + + func testToURLRequestWithoutQueries() throws { + let baseURL = URL(string: "https://api.anthropic.com")! + var mockRequest = MockRequest() + mockRequest.queries = nil + + let urlRequest = try mockRequest.toURLRequest(for: baseURL) + + XCTAssertEqual(urlRequest.url?.absoluteString, "https://api.anthropic.com/test") + XCTAssertEqual(urlRequest.httpMethod, "POST") + + let bodyData = try XCTUnwrap(urlRequest.httpBody) + let decodedBody = try JSONDecoder().decode([String: String].self, from: bodyData) + XCTAssertEqual(decodedBody, ["test": "data"]) + } +} diff --git a/Tests/AnthropicSwiftSDKTests/Network/Request/RetrieveMessageBatchResultsRequestTests.swift b/Tests/AnthropicSwiftSDKTests/Network/Request/RetrieveMessageBatchResultsRequestTests.swift new file mode 100644 index 0000000..ea031d6 --- /dev/null +++ b/Tests/AnthropicSwiftSDKTests/Network/Request/RetrieveMessageBatchResultsRequestTests.swift @@ -0,0 +1,23 @@ +// +// RetrieveMessageBatchResultsRequestTests.swift +// AnthropicSwiftSDK +// +// Created by 伊藤史 on 2024/10/22. +// + +import XCTest +@testable import AnthropicSwiftSDK + +final class RetrieveMessageBatchResultsRequestTests: XCTestCase { + + func testRetrieveMessageBatchResultsRequest() { + let batchId = "test_batch_123" + let request = RetrieveMessageBatchResultsRequest(batchId: batchId) + + XCTAssertEqual(request.method, .get) + XCTAssertEqual(request.path, "\(RequestType.batches.basePath)/\(batchId)/results") + XCTAssertNil(request.queries) + XCTAssertNil(request.body) + XCTAssertEqual(request.batchId, batchId) + } +} \ No newline at end of file diff --git a/Tests/AnthropicSwiftSDKTests/Network/Request/RetrieveMessageBatchesRequestTests.swift b/Tests/AnthropicSwiftSDKTests/Network/Request/RetrieveMessageBatchesRequestTests.swift new file mode 100644 index 0000000..1c7c29f --- /dev/null +++ b/Tests/AnthropicSwiftSDKTests/Network/Request/RetrieveMessageBatchesRequestTests.swift @@ -0,0 +1,23 @@ +// +// RetrieveMessageBatchesRequestTests.swift +// AnthropicSwiftSDK +// +// Created by 伊藤史 on 2024/10/22. +// + +import XCTest +@testable import AnthropicSwiftSDK + +final class RetrieveMessageBatchesRequestTests: XCTestCase { + + func testRetrieveMessageBatchesRequest() { + let testBatchId = "test_batch_123" + let request = RetrieveMessageBatchesRequest(batchId: testBatchId) + + XCTAssertEqual(request.method, .get) + XCTAssertEqual(request.path, "\(RequestType.batches.basePath)/\(testBatchId)") + XCTAssertNil(request.queries) + XCTAssertNil(request.body) + XCTAssertEqual(request.batchId, testBatchId) + } +} \ No newline at end of file