🕌

Anthropic Claude APIのSwiftクライアントを書いた

2024/09/12に公開

新しめのSwiftのAPIを触って適当な大きさのいい感じのものを書いてみたいな〜と思っていたらAnthropic ClaudeがAPIを公開していたのでSwiftのクライアントを書いた。

https://github.com/fumito-ito/AnthropicSwiftSDK

類似のものとしてはSwiftAnthropicが存在する。

https://github.com/jamesrochabrun/SwiftAnthropic

これは何?

SwiftからAnthropic ClaudeのAPIを叩くことができる。以下の機能をサポートした。

Messages API/Create a Message

素朴なHTTP Request / ResponseでClaude APIにメッセージを投げて受け取るAPI。ChatGPTしかり、Claudeしかり、LLMのAPIはデカめのコンテキストを受け付けてデカめのレスポンスを返すみたいなのが現在の期待値なので、基本的に後述するStreaming Messagesがよく使われる。このAPIが直接使われる機会はそう多くないのではないかと思う。

内容は普通のAPIリクエストなので、何もいうことはない。

let anthropic = Anthropic(apiKey: "YOUR_OWN_API_KEY")

let message = Message(role: .user, content: [.text("This is test text")])
let response = try await anthropic.messages.createMessage([message], maxTokens: 1024)

for content in response.content {
    switch content {
    case .text(let text):
        print(text)
    case .image(let imageContent):
        // handle base64 encoded image content
    }
}

Messages API/Streaming Messages

LLMといえばこちらの質問に対して回答が徐々にウニョウニョ〜と出てくる印象があるが、概ねStreaming Messagesのようにレスポンスをチャンクで返しているものだと思われる。

Claude APIのチャンクストリームはイベントオブジェクトに表示するべき文字列やオブジェクトなどが包まれて流れてくる。実際のハンドリングは以下のようになる。

let anthropic = Anthropic(apiKey: "YOUR_OWN_API_KEY")

let message = Message(role: .user, content: [.text("This is test text")])
let stream = try await anthropic.messages.streamMessage([message], maxTokens: 1024)

for try await chunk in stream {
    switch chunk.type {
    // 何種類かイベントが用意されているが、基本的に結果のオブジェクトが含まれているのは `.contentBlockDelta` に該当するイベント
    case .contentBlockDelta:
        // chunkを型に応じて適切なレスポンスオブジェクトに変換することで中身を取り出せる
        if let response = chunk as? StreamingContentBlockDeltaResponse {
            print(response.delta.text)
        }
    default:
        break
    }
}

Swiftの文脈で言えば AsyncThrowingStream を利用してAPIからのチャンクストリームをハンドリングする形になった。

AsyncThrowingStream は単純な用途であれば素朴に書くことができるのだが、例えば「あるストリームの中で条件Aを満たす事象が起きたら新しいストリームを作成して処理を引き継ぐ。起きなかったらそのまま継続する」みたいな複雑な処理をさくっと書くことは難しかった。

Claude APIの文脈では「Tool Useのリクエストが返ってきたらTool Useの結果を取得してもう一度APIに投げ返す(そして結果を待ち受ける)」みたいな処理が該当するのだが、うんうん苦しんだ結果、map などを利用せずに AsyncThrowingStream を新しく生成するという選択肢をとっている。

Rxだとちょっと考えればこれくらいの処理はかける印象なので、多分に慣れの問題があると思っているが、習熟を待つよりもさっさと完成させたかったので今のような形になっている。Async強者ニキはPRをください。待っています。

AWS Bedrock, Vertex AI 経由のアクセス

Claude APIはAnthropicの自前ホストの他にAWS Bedrock, Vertex AI経由でアクセスすることもできる。このライブラリでは、これらサードパーティAPI経由でのアクセスもサポートした。

AWS Bedrockは公式のSDK経由でClaudeにアクセスできる。せっかくなので BedrockRuntimeClient のインスタンスからAnthropic ClaudeのAPIクライアントを生成できるようにした。

let client = try BedrockRuntimeClient(region: "us-west-2")
let anthropic = client.useAnthropic()

let response = try await anthropic.messages.createMessage(Message(role: .user, content: [.text("This is test text")]), maxTokens: 1024)
for content in response.content {
    switch content {
    case .text(let text):
        print(text)
    case .image(let imageContent):
        // handle base64 encoded image content
    }
}

Vertex AIにはそれらしい(公式の)Swiftのクライアントが存在せず、自前でリクエストを書くことになってしまった。

Firebase SDKでもVertex AIのbetaサポートが始まっているが、これはあくまでもGemini APIを叩くことに特化しているため、Claude APIのようなサードパーティーのAPIを利用するためのものではないとのことだった。

https://github.com/firebase/firebase-ios-sdk/discussions/13247

今回はガーっと完成まで突っ走りたいという都合上、全てのプロダクトを一つのパッケージに含めてしまっているが、個人的にこれは失敗したなと思っている。

BedrockやVertex AIのAPIサポートは本来、それらのサービスを利用しているユーザーだけが必要とするものだ。だが、現時点では AnthropicSwiftSDK をPackageのdependencyに追加しただけで awslabs/aws-sdk-swift などへの依存がプロダクトに追加されてしまう。

こうなると不要な依存が表示されて気持ちが悪いし、renovateなどを利用しているプロジェクトではあまり意味のないパッケージの更新でPRが作成されて煩わしい。

今後、パッケージを分けていきたいと考えているが、実はテスト用のプロダクトをパッケージ内で共有している。このプロダクトは外部からアクセスできるようにしたくないが、パッケージを分ける場合には公開しないといけないので悩ましい。

この辺も知見が欲しいところ、いい感じのPR待ってます。

Tool Use (Function Calling)

最近のLLMサービスは関数定義を受け取り、必要に応じてクライアントに関数の呼び出しを依頼することで、より多様なタスクを実行できるようにする機能がある。呼び方は色々あるが、ChatGPTでは Function Calling 、Anthropic Claudeでは Tool Use という名前で呼ばれている。

https://docs.anthropic.com/en/docs/build-with-claude/tool-use

大まかなフローは以下のようになる。

今回は Tool Use 用の関数定義を受け取れるようにし、同時に以下の部分をSDK内で再起的にハンドリングできるようにした。

他のSDKを見てみると、概ね関数定義を表現する型をユーザーに記述させることでこの機能を実現している。

https://github.com/jamesrochabrun/SwiftAnthropic/blob/a624abcf464117a0c113043d0b94f294b4bed4d1/Examples/SwiftAnthropicExample/SwiftAnthropicExample/FunctionCalling/MessageFunctionCallingDemoView.swift#L18-L32

https://github.com/firebase/firebase-ios-sdk/blob/16dd4eed59bc6828d5319a1e75d3aa239430a6a6/FirebaseVertexAI/Sample/FunctionCallingSample/ViewModels/FunctionCallingViewModel.swift#L41-L64

しかし、これは面倒すぎないだろうか?しかも、この方法ではLLMに渡す関数定義と実際に実装している関数の内容が常に完全に一致していなければならない。そんなものは現実的ではない。少なくとも私にはメンテナンス不可能だ。

一方、C#のClaude API実装である Cysharp/Claudia では [ClaudiaFunction] というアノテーションを関数につけるだけで関数定義やレスポンスからの呼び出しを可能にしている。どう考えても、こちらの方が合理的である。

https://github.com/Cysharp/Claudia

Claudia ではC#の Source Generator という機能を利用することで、これを実現していた。Swiftにも同じようにソースコードを生成する Swift Macros があるので、アノテーションをつけるだけで Tool Use として必要なインターフェースである関数定義を表現するJSONオブジェクト呼び出し用の関数 を自動生成できたら面白いんじゃないかな〜と思ってマクロを書いてみた。

https://github.com/fumito-ito/FunctionCalling

AnthropicSwiftSDK にはこのマクロを統合してある。そのため、以下のように書くだけで必要に応じて getStockPrice が呼び出され、結果をClaude APIに渡すことができるようになった。

// Tool Useで利用する関数のコンテナ。Function Callingの方が一般的な名前なので、採用している
@FunctionCalling(service: .claude)
struct MyFunctionTools: ToolContainer {
    // このアノテーションをつけることで関数定義を表現するJSONオブジェクトとClaude APIから呼び出すための関数が自動的に生成される
    @CallableFunction
    /// Get the current stock price for a given ticker symbol
    ///
    /// - Parameter: The stock ticker symbol, e.g. AAPL for Apple Inc.
    func getStockPrice(ticker: String) async throws -> String {
        // code to return stock price of passed ticker
    }
}

// Claudeから `getStockPrice` を呼び出すレスポンスが返った場合には自動的に呼び出される
let result = try await Anthropic(apiKey: "your_claude_api_key")
    .createMessage(
        [message],
        maxTokens: 1024,
        toolContainer: MyFunctionTools() // <= ここにコンテナのインスタンスを渡している
    )

Swift Macrosは楽しかったが、かなり長くなるので別の記事で書くことにする。

まとめ

  • Anthropic ClaudeのAPIを叩くSDKを作った。 AsyncThrowingStream, Swift Macros など、比較的新しめのSwift APIを触れて楽しかった。
  • AsyncThrowingStream の扱いがこなれていない。PRください。
  • パッケージを切り分けたいがテスト用の内部パッケージを外部から見えないように共有したい。PRください。
  • マクロとして書いた FunctionCalling は別の記事に詳細を書く

Discussion