はじめに
いよいよ、これまでの知識を総動員して、LLM(大規模言語モデル)をAndroidアプリから実際に呼び出す方法を学びます。今回は、代表的なモデルであるOpenAIのChatGPTとAnthropicのClaudeを例に、そのAPI連携の実装方法を解説します。
1. OpenAIとAnthropicのAPIの比較
| 特徴 | OpenAI API (GPT-4, GPT-3.5) | Anthropic Claude API |
|---|---|---|
| APIエンドポイント | https://api.openai.com/v1/chat/completions |
https://api.anthropic.com/v1/messages |
| 認証方法 | Authorization: Bearer YOUR_API_KEY |
x-api-key: YOUR_API_KEYanthropic-version: 2023-06-01
|
| リクエスト形式 |
messages配列 |
messages配列 |
| レスポンス形式 |
choices配列 |
content配列 |
| ストリーミング | サポート(stream: true) |
サポート(stream: true) |
| 料金体系 | トークン単価制 | トークン単価制 |
両者のAPIは基本的な設計思想は似ていますが、リクエストヘッダーやレスポンスの構造に重要な違いがあります。特に、AnthropicはAPIバージョンの指定が必須である点に注意が必要です。
2. 依存関係の設定
まず、build.gradle.kts(app level)に必要な依存関係を追加します。
dependencies {
// Retrofit2とJSON変換
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
// HTTP クライアント
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
// コルーチン
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
// ViewModel
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0")
}
3. データモデルの定義
共通メッセージクラス
data class Message(
val role: String, // "user", "assistant", "system"
val content: String
)
OpenAI向けデータクラス
// リクエストボディ
data class OpenAIRequest(
val model: String,
val messages: List<Message>,
val stream: Boolean = false,
val temperature: Double = 0.7,
val max_tokens: Int? = null
)
// レスポンスボディ
data class OpenAIResponse(
val id: String,
val object: String,
val created: Long,
val model: String,
val choices: List<OpenAIChoice>,
val usage: Usage?
)
data class OpenAIChoice(
val index: Int,
val message: Message,
val finish_reason: String?
)
data class Usage(
val prompt_tokens: Int,
val completion_tokens: Int,
val total_tokens: Int
)
Anthropic向けデータクラス
// リクエストボディ
data class AnthropicRequest(
val model: String,
val messages: List<Message>,
val max_tokens: Int,
val stream: Boolean = false,
val temperature: Double = 0.7
)
// レスポンスボディ
data class AnthropicResponse(
val id: String,
val type: String,
val role: String,
val content: List<ContentBlock>,
val model: String,
val stop_reason: String?,
val usage: AnthropicUsage?
)
data class ContentBlock(
val type: String,
val text: String
)
data class AnthropicUsage(
val input_tokens: Int,
val output_tokens: Int
)
4. APIサービスインターフェースの定義
OpenAI APIサービス
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.POST
interface OpenAIService {
@POST("v1/chat/completions")
suspend fun createChatCompletion(
@Body request: OpenAIRequest
): Response<OpenAIResponse>
}
Anthropic APIサービス
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.POST
interface AnthropicService {
@POST("v1/messages")
suspend fun createMessage(
@Body request: AnthropicRequest
): Response<AnthropicResponse>
}
重要な変更点:
-
Response<T>でラップすることで、HTTPステータスコードやエラーハンドリングが可能 - ヘッダー情報はInterceptorで管理するため、アノテーションから削除
5. セキュアなAPI設定とInterceptor
APIキーの安全な管理
まず、local.propertiesにAPIキーを保存します:
# local.properties (Gitにコミットしない)
OPENAI_API_KEY="your-openai-api-key"
ANTHROPIC_API_KEY="your-anthropic-api-key"
build.gradle.ktsで環境変数として読み込み:
android {
// ...
buildTypes {
debug {
buildConfigField("String", "OPENAI_API_KEY", "\"${project.findProperty("OPENAI_API_KEY")}\"")
buildConfigField("String", "ANTHROPIC_API_KEY", "\"${project.findProperty("ANTHROPIC_API_KEY")}\"")
}
release {
// 本番環境では環境変数から取得
buildConfigField("String", "OPENAI_API_KEY", "\"${System.getenv("OPENAI_API_KEY")}\"")
buildConfigField("String", "ANTHROPIC_API_KEY", "\"${System.getenv("ANTHROPIC_API_KEY")}\"")
}
}
buildFeatures {
buildConfig = true
}
}
Retrofitクライアントの設定
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
object ApiClient {
// OpenAI用クライアント
val openAIService: OpenAIService by lazy {
createRetrofit(
baseUrl = "https://api.openai.com/",
interceptor = createOpenAIInterceptor()
).create(OpenAIService::class.java)
}
// Anthropic用クライアント
val anthropicService: AnthropicService by lazy {
createRetrofit(
baseUrl = "https://api.anthropic.com/",
interceptor = createAnthropicInterceptor()
).create(AnthropicService::class.java)
}
private fun createOpenAIInterceptor(): Interceptor {
return Interceptor { chain ->
val originalRequest = chain.request()
val newRequest = originalRequest.newBuilder()
.addHeader("Authorization", "Bearer ${BuildConfig.OPENAI_API_KEY}")
.addHeader("Content-Type", "application/json")
.build()
chain.proceed(newRequest)
}
}
private fun createAnthropicInterceptor(): Interceptor {
return Interceptor { chain ->
val originalRequest = chain.request()
val newRequest = originalRequest.newBuilder()
.addHeader("x-api-key", BuildConfig.ANTHROPIC_API_KEY)
.addHeader("anthropic-version", "2023-06-01")
.addHeader("Content-Type", "application/json")
.build()
chain.proceed(newRequest)
}
}
private fun createRetrofit(baseUrl: String, interceptor: Interceptor): Retrofit {
val loggingInterceptor = HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG) {
HttpLoggingInterceptor.Level.BODY
} else {
HttpLoggingInterceptor.Level.NONE
}
}
val okHttpClient = OkHttpClient.Builder()
.addInterceptor(interceptor)
.addInterceptor(loggingInterceptor)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build()
return Retrofit.Builder()
.baseUrl(baseUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
}
}
6. Repository層の実装
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
sealed class ApiResult<T> {
data class Success<T>(val data: T) : ApiResult<T>()
data class Error<T>(val exception: Throwable) : ApiResult<T>()
data class HttpError<T>(val code: Int, val message: String) : ApiResult<T>()
}
class ChatRepository {
suspend fun getChatCompletionFromOpenAI(messages: List<Message>): ApiResult<Message> {
return withContext(Dispatchers.IO) {
try {
val request = OpenAIRequest(
model = "gpt-3.5-turbo",
messages = messages,
max_tokens = 1000,
temperature = 0.7
)
val response = ApiClient.openAIService.createChatCompletion(request)
if (response.isSuccessful) {
response.body()?.let { body ->
val assistantMessage = body.choices.firstOrNull()?.message
if (assistantMessage != null) {
ApiResult.Success(assistantMessage)
} else {
ApiResult.Error(Exception("No response from OpenAI"))
}
} ?: ApiResult.Error(Exception("Empty response body"))
} else {
ApiResult.HttpError(response.code(), response.message())
}
} catch (e: Exception) {
ApiResult.Error(e)
}
}
}
suspend fun getChatCompletionFromAnthropic(messages: List<Message>): ApiResult<Message> {
return withContext(Dispatchers.IO) {
try {
val request = AnthropicRequest(
model = "claude-3-sonnet-20240229",
messages = messages,
max_tokens = 1000,
temperature = 0.7
)
val response = ApiClient.anthropicService.createMessage(request)
if (response.isSuccessful) {
response.body()?.let { body ->
val textContent = body.content.firstOrNull { it.type == "text" }?.text
if (textContent != null) {
ApiResult.Success(Message(role = "assistant", content = textContent))
} else {
ApiResult.Error(Exception("No text content in response"))
}
} ?: ApiResult.Error(Exception("Empty response body"))
} else {
ApiResult.HttpError(response.code(), response.message())
}
} catch (e: Exception) {
ApiResult.Error(e)
}
}
}
}
7. ViewModel層での統合
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
data class ChatUiState(
val messages: List<Message> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null
)
class ChatViewModel : ViewModel() {
private val repository = ChatRepository()
private val _uiState = MutableStateFlow(ChatUiState())
val uiState: StateFlow<ChatUiState> = _uiState
fun sendMessage(userMessage: String, useOpenAI: Boolean = true) {
viewModelScope.launch {
val newMessage = Message(role = "user", content = userMessage)
val updatedMessages = _uiState.value.messages + newMessage
_uiState.value = _uiState.value.copy(
messages = updatedMessages,
isLoading = true,
error = null
)
val result = if (useOpenAI) {
repository.getChatCompletionFromOpenAI(updatedMessages)
} else {
repository.getChatCompletionFromAnthropic(updatedMessages)
}
when (result) {
is ApiResult.Success -> {
_uiState.value = _uiState.value.copy(
messages = updatedMessages + result.data,
isLoading = false
)
}
is ApiResult.Error -> {
_uiState.value = _uiState.value.copy(
isLoading = false,
error = "通信エラー: ${result.exception.message}"
)
}
is ApiResult.HttpError -> {
_uiState.value = _uiState.value.copy(
isLoading = false,
error = "API エラー (${result.code}): ${result.message}"
)
}
}
}
}
fun clearError() {
_uiState.value = _uiState.value.copy(error = null)
}
}
8. 権限とネットワークセキュリティの設定
AndroidManifest.xml
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="false"
... >
network_security_config.xml
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="false">
<domain includeSubdomains="true">api.openai.com</domain>
<domain includeSubdomains="true">api.anthropic.com</domain>
</domain-config>
</network-security-config>
まとめ
主要なポイント
-
セキュリティ: APIキーは
BuildConfig経由で安全に管理 -
エラーハンドリング:
sealed classを使った型安全なエラー処理 - 非同期処理: Coroutinesを使ったスレッドセーフな実装
- アーキテクチャ: MVVM + Repository パターンで保守性を向上
- ネットワーク: 適切なタイムアウト設定とログ出力
注意事項
-
料金: 両APIとも従量課金制です。テスト時は
max_tokensを適切に設定しましょう - レート制限: 各APIには利用制限があります。本番環境では適切な制御が必要です
-
APIバージョン: Anthropic APIは
anthropic-versionヘッダーの指定が必須です
次回は、この基盤を使ってリアルタイムチャット UI を構築し、ユーザビリティを向上させる方法を学習します!