なぜこの構成か: Claude APIとの通信ロジック(リクエスト生成・パース・リトライ)はプラットフォーム差異がないため、commonMainに集中させます。プラットフォーム固有の処理(SSL証明書ピニング、システムキーチェーンへのAPIキー保存など)だけをandroidMain/iosMainに分離する設計です。この分離を徹底することで、バグ修正や機能追加を一箇所に留められます。
// shared/src/commonMain/kotlin/com/example/ai/ClaudeClient.ktimport io.ktor.client.*import io.ktor.client.plugins.contentnegotiation.*import io.ktor.client.plugins.logging.*import io.ktor.client.request.*import io.ktor.client.statement.*import io.ktor.http.*import io.ktor.serialization.kotlinx.json.*import kotlinx.coroutines.flow.Flowimport kotlinx.coroutines.flow.flowimport kotlinx.serialization.json.Jsonclass ClaudeClient( private val apiKey: String, private val httpClient: HttpClient = createHttpClient()) { companion object { private const val BASE_URL = "https://api.anthropic.com/v1" private const val API_VERSION = "2023-06-01" private const val DEFAULT_MODEL = "claude-sonnet-4-6" private const val DEFAULT_MAX_TOKENS = 4096 } /** * 通常のメッセージ送信(レスポンス全体を一括取得) * シンプルなQ&Aやオフライン処理に適しています */ suspend fun sendMessage( messages: List<ClaudeMessage>, systemPrompt: String? = null, model: String = DEFAULT_MODEL, maxTokens: Int = DEFAULT_MAX_TOKENS ): Result<ClaudeResponse> = runCatching { val request = ClaudeRequest( model = model, maxTokens = maxTokens, system = systemPrompt, messages = messages, stream = false ) val response = httpClient.post("$BASE_URL/messages") { header("x-api-key", apiKey) header("anthropic-version", API_VERSION) contentType(ContentType.Application.Json) setBody(request) } if (\!response.status.isSuccess()) { val errorBody = response.bodyAsText() throw ClaudeApiException( statusCode = response.status.value, message = parseErrorMessage(errorBody) ) } response.body<ClaudeResponse>() } /** * ストリーミングメッセージ送信 * テキスト生成の進行状況をリアルタイムでUIに反映できます * Flowで各チャンクを順次emitします */ fun sendMessageStreaming( messages: List<ClaudeMessage>, systemPrompt: String? = null, model: String = DEFAULT_MODEL, maxTokens: Int = DEFAULT_MAX_TOKENS ): Flow<StreamEvent> = flow { val request = ClaudeRequest( model = model, maxTokens = maxTokens, system = systemPrompt, messages = messages, stream = true ) // SSE(Server-Sent Events)形式でレスポンスを受信します httpClient.preparePost("$BASE_URL/messages") { header("x-api-key", apiKey) header("anthropic-version", API_VERSION) header("Accept", "text/event-stream") contentType(ContentType.Application.Json) setBody(request) }.execute { response -> if (\!response.status.isSuccess()) { throw ClaudeApiException( statusCode = response.status.value, message = "Streaming request failed: ${response.status}" ) } // バイトストリームをSSEイベントに変換します val channel = response.bodyAsChannel() while (\!channel.isClosedForRead) { // 1行ずつ読み込みます(SSE形式: "data: {...}") val line = channel.readUTF8Line() ?: break when { line.startsWith("data: ") -> { val data = line.removePrefix("data: ") if (data == "[DONE]") { emit(StreamEvent.Done) return@execute } // JSONパースしてイベントを分類します val event = parseStreamEvent(data) if (event \!= null) { emit(event) } } line == "" -> { // SSEイベントの区切り(空行) } } } } } private fun parseErrorMessage(body: String): String { return try { // エラーレスポンスから message フィールドを抽出 val json = Json { ignoreUnknownKeys = true } val error = json.decodeFromString<ApiErrorResponse>(body) error.error.message } catch (e: Exception) { "API error: $body" } } private fun parseStreamEvent(data: String): StreamEvent? { return try { val json = Json { ignoreUnknownKeys = true } val event = json.decodeFromString<RawStreamEvent>(data) when (event.type) { "content_block_delta" -> { val text = event.delta?.text ?: return null StreamEvent.TextDelta(text) } "message_start" -> { val tokens = event.message?.usage?.inputTokens ?: 0 StreamEvent.MessageStart(tokens) } "message_delta" -> { val tokens = event.usage?.outputTokens ?: 0 StreamEvent.MessageStop(tokens) } else -> null } } catch (e: Exception) { null } }}
Step 3: データモデルの定義
// shared/src/commonMain/kotlin/com/example/ai/ClaudeModels.ktimport kotlinx.serialization.SerialNameimport kotlinx.serialization.Serializable// --- リクエストモデル ---@Serializabledata class ClaudeRequest( val model: String, @SerialName("max_tokens") val maxTokens: Int, val system: String? = null, val messages: List<ClaudeMessage>, val stream: Boolean = false)@Serializabledata class ClaudeMessage( val role: String, // "user" または "assistant" val content: String) { companion object { fun user(text: String) = ClaudeMessage("user", text) fun assistant(text: String) = ClaudeMessage("assistant", text) }}// --- レスポンスモデル ---@Serializabledata class ClaudeResponse( val id: String, val type: String, val role: String, val content: List<ContentBlock>, val model: String, @SerialName("stop_reason") val stopReason: String? = null, val usage: UsageInfo) { // 最初のテキストコンテンツを取得するヘルパー val text: String get() = content.firstOrNull()?.text ?: ""}@Serializabledata class ContentBlock( val type: String, val text: String = "")@Serializabledata class UsageInfo( @SerialName("input_tokens") val inputTokens: Int, @SerialName("output_tokens") val outputTokens: Int)// --- ストリーミングイベント ---sealed class StreamEvent { data class TextDelta(val text: String) : StreamEvent() data class MessageStart(val inputTokens: Int) : StreamEvent() data class MessageStop(val outputTokens: Int) : StreamEvent() data object Done : StreamEvent()}// --- ストリーミング内部モデル ---@Serializabledata class RawStreamEvent( val type: String, val delta: DeltaContent? = null, val message: MessageContent? = null, val usage: UsageContent? = null)@Serializabledata class DeltaContent( val type: String = "", val text: String = "")@Serializabledata class MessageContent( val usage: UsageInfo? = null)@Serializabledata class UsageContent( @SerialName("output_tokens") val outputTokens: Int = 0)// --- エラーモデル ---@Serializabledata class ApiErrorResponse( val type: String, val error: ApiError)@Serializabledata class ApiError( val type: String, val message: String)class ClaudeApiException( val statusCode: Int, override val message: String) : Exception(message) { val isRateLimit: Boolean get() = statusCode == 429 val isServerError: Boolean get() = statusCode >= 500 val isAuthError: Boolean get() = statusCode == 401}
タイムアウト設定が重要な理由: Claude APIのレスポンス生成は、特に長文出力や複雑なリクエストで数十秒かかることがあります。デフォルトのタイムアウト(多くの場合10〜15秒)では正常なレスポンスが途中で切断されてしまいます。readTimeout(120, TimeUnit.SECONDS)は過剰に見えますが、ストリーミングでの長文生成では必要な余裕です。
Step 5: リトライポリシーの実装
本番アプリでは、ネットワーク断やレート制限への対処が不可欠です。
// shared/src/commonMain/kotlin/com/example/ai/RetryPolicy.ktimport kotlinx.coroutines.delayimport kotlin.math.minimport kotlin.math.powclass RetryPolicy( private val maxRetries: Int = 3, private val baseDelayMs: Long = 1000L, private val maxDelayMs: Long = 30_000L) { /** * 指数バックオフでリトライを実行します * * リトライすべき条件: * - 429 (Rate Limit): Anthropicのレート制限に達した場合 * - 500/502/503 (Server Error): Anthropicサーバーの一時障害 * * リトライしてはいけない条件: * - 401 (Auth): APIキーが無効 — リトライしても解決しません * - 400 (Bad Request): リクエスト形式エラー — リトライしても解決しません */ suspend fun <T> execute(block: suspend () -> T): T { var lastException: Exception? = null repeat(maxRetries + 1) { attempt -> try { return block() } catch (e: ClaudeApiException) { lastException = e if (\!e.isRetryable) { // リトライ不可なエラーは即座に再スローします throw e } if (attempt < maxRetries) { val delayMs = calculateDelay(attempt, e.isRateLimit) delay(delayMs) } } } throw lastException ?: IllegalStateException("Retry failed with unknown error") } private fun calculateDelay(attempt: Int, isRateLimit: Boolean): Long { // レート制限の場合は長めに待ちます(最低5秒) val baseMs = if (isRateLimit) maxOf(baseDelayMs, 5000L) else baseDelayMs // 指数バックオフ: 1秒 → 2秒 → 4秒 → ...(最大30秒) val exponential = (baseMs * 2.0.pow(attempt)).toLong() // ジッターで同時リトライによるスパイクを防ぎます val jitter = (0..500).random().toLong() return min(exponential + jitter, maxDelayMs) }}// ClaudeApiExceptionにリトライ可否を追加しますval ClaudeApiException.isRetryable: Boolean get() = isRateLimit || isServerError
Step 6: ViewModelとの統合パターン
iOSとAndroidで共通のViewModelロジックを実装します。
// shared/src/commonMain/kotlin/com/example/ai/ChatViewModel.ktimport kotlinx.coroutines.CoroutineScopeimport kotlinx.coroutines.Dispatchersimport kotlinx.coroutines.flow.*import kotlinx.coroutines.launchimport kotlinx.coroutines.withContextdata class ChatUiState( val messages: List<ChatMessage> = emptyList(), val streamingText: String = "", // 生成中テキスト(リアルタイム表示用) val isLoading: Boolean = false, val error: String? = null)data class ChatMessage( val id: String, val role: String, val text: String, val isStreaming: Boolean = false)class ChatViewModel( private val claudeClient: ClaudeClient, private val retryPolicy: RetryPolicy = RetryPolicy(), private val coroutineScope: CoroutineScope) { private val _uiState = MutableStateFlow(ChatUiState()) val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow() // 会話履歴(APIに送信する形式) private val conversationHistory = mutableListOf<ClaudeMessage>() // 送信中のジョブ(キャンセル用) private var currentJob: kotlinx.coroutines.Job? = null fun sendMessage(userText: String) { if (userText.isBlank() || _uiState.value.isLoading) return // ユーザーメッセージをUIと履歴に追加します val userMessage = ChatMessage( id = generateId(), role = "user", text = userText ) conversationHistory.add(ClaudeMessage.user(userText)) _uiState.update { state -> state.copy( messages = state.messages + userMessage, isLoading = true, error = null, streamingText = "" ) } currentJob = coroutineScope.launch { try { retryPolicy.execute { var fullText = "" claudeClient.sendMessageStreaming( messages = trimmedHistory(), systemPrompt = "あなたは丁寧で正確なアシスタントです。" ).collect { event -> when (event) { is StreamEvent.TextDelta -> { fullText += event.text // UIにリアルタイム反映します _uiState.update { it.copy(streamingText = fullText) } } is StreamEvent.Done -> { // ストリーミング完了: 会話履歴に追加してUIを更新します conversationHistory.add(ClaudeMessage.assistant(fullText)) val assistantMessage = ChatMessage( id = generateId(), role = "assistant", text = fullText ) _uiState.update { state -> state.copy( messages = state.messages + assistantMessage, streamingText = "", isLoading = false ) } } else -> { /* MessageStart等は今回は省略 */ } } } } } catch (e: ClaudeApiException) { val errorMessage = when { e.isAuthError -> "APIキーが無効です。設定を確認してください。" e.isRateLimit -> "リクエストが多すぎます。しばらく待ってから再試行してください。" e.isServerError -> "サービスが一時的に利用できません。" else -> "エラーが発生しました: ${e.message}" } _uiState.update { state -> state.copy(isLoading = false, error = errorMessage) } } } } /** * 進行中のリクエストをキャンセルします * UIの「停止」ボタンから呼び出します */ fun cancelStreaming() { currentJob?.cancel() _uiState.update { it.copy(isLoading = false, streamingText = "") } } /** * Context Windowを節約するため直近20件のみ送信します * 会話が長くなるほどコストとレイテンシが増加するため必要な処理です */ private fun trimmedHistory(maxMessages: Int = 20): List<ClaudeMessage> { return conversationHistory.takeLast(maxMessages) } private fun generateId(): String = "msg_${ kotlinx.datetime.Clock.System.now().toEpochMilliseconds() }"}