1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Android開発30日間マスターシリーズ - Day23: OpenAI API / Anthropic Claude APIとの連携 - Android アプリからのLLM呼び出し

Posted at

はじめに

いよいよ、これまでの知識を総動員して、LLM(大規模言語モデル)をAndroidアプリから実際に呼び出す方法を学びます。今回は、代表的なモデルであるOpenAIのChatGPTAnthropicの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_KEY
anthropic-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>

まとめ

主要なポイント

  1. セキュリティ: APIキーはBuildConfig経由で安全に管理
  2. エラーハンドリング: sealed classを使った型安全なエラー処理
  3. 非同期処理: Coroutinesを使ったスレッドセーフな実装
  4. アーキテクチャ: MVVM + Repository パターンで保守性を向上
  5. ネットワーク: 適切なタイムアウト設定とログ出力

注意事項

  • 料金: 両APIとも従量課金制です。テスト時はmax_tokensを適切に設定しましょう
  • レート制限: 各APIには利用制限があります。本番環境では適切な制御が必要です
  • APIバージョン: Anthropic APIはanthropic-versionヘッダーの指定が必須です

次回は、この基盤を使ってリアルタイムチャット UI を構築し、ユーザビリティを向上させる方法を学習します!

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?