diff --git a/shared/src/androidHostTest/kotlin/com/github/worn/remote/ClaudeApiClientTest.kt b/shared/src/androidHostTest/kotlin/com/github/worn/remote/ClaudeApiClientTest.kt new file mode 100644 index 0000000..0e4f60c --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/github/worn/remote/ClaudeApiClientTest.kt @@ -0,0 +1,118 @@ +package com.github.worn.remote + +import com.github.worn.data.source.remote.ClaudeApiClient +import com.github.worn.fake.FakeSecretStore +import com.github.worn.util.secret.SecretStore +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.utils.io.ByteReadChannel +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ClaudeApiClientTest { + + private fun secretStore(): SecretStore = + FakeSecretStore(mutableMapOf(SecretStore.CLAUDE_KEY to "sk-ant-test")) + + private fun jsonHeaders() = headersOf(HttpHeaders.ContentType, "application/json") + + private fun analysisResponse() = """ + {"content":[{"type":"text","text":"{\"description\":\"a tee\", + \"suggested_category\":\"TOP\",\"colors\":[\"black\"],\"seasons\":[\"SUMMER\"], + \"tags\":[\"casual\"]}"}]} + """.trimIndent().replace("\n", "") + + /** + * The image source's `type` and `media_type` are Kotlin default values. `Json` omits defaults + * unless `encodeDefaults` is set, which would send a source block the API rejects. + */ + @Test + fun `analyzeImage sends a complete image source block`() = runTest { + var body = "" + val engine = MockEngine { request -> + body = (request.body as io.ktor.http.content.TextContent).text + respond(ByteReadChannel(analysisResponse()), HttpStatusCode.OK, jsonHeaders()) + } + + ClaudeApiClient(HttpClient(engine), secretStore()) + .analyzeImage(byteArrayOf(1, 2, 3)) + + assertTrue(body.contains("\"media_type\":\"image/jpeg\""), "missing media_type: $body") + assertTrue(body.contains("\"type\":\"base64\""), "missing source type: $body") + } + + @Test + fun `requests target a supported model with thinking disabled`() = runTest { + var body = "" + val engine = MockEngine { request -> + body = (request.body as io.ktor.http.content.TextContent).text + respond(ByteReadChannel(analysisResponse()), HttpStatusCode.OK, jsonHeaders()) + } + + ClaudeApiClient(HttpClient(engine), secretStore()) + .analyzeImage(byteArrayOf(1, 2, 3)) + + assertTrue(body.contains("\"model\":\"claude-sonnet-5\""), "unexpected model: $body") + assertTrue(body.contains("\"thinking\":{\"type\":\"disabled\"}"), "no thinking cfg: $body") + } + + /** Null-valued blocks must be omitted, not serialised as explicit nulls. */ + @Test + fun `text content blocks omit the unused image source`() = runTest { + var body = "" + val engine = MockEngine { request -> + body = (request.body as io.ktor.http.content.TextContent).text + respond(ByteReadChannel(analysisResponse()), HttpStatusCode.OK, jsonHeaders()) + } + + ClaudeApiClient(HttpClient(engine), secretStore()) + .analyzeImage(byteArrayOf(1, 2, 3)) + + assertFalse(body.contains("\"source\":null"), "explicit null source: $body") + assertFalse(body.contains("\"text\":null"), "explicit null text: $body") + } + + @Test + fun `a credit balance error surfaces the message from the API`() = runTest { + val engine = MockEngine { + respond( + ByteReadChannel( + """{"type":"error","error":{"type":"invalid_request_error","message":""" + + """"Your credit balance is too low to access the Anthropic API."}}""", + ), + HttpStatusCode.BadRequest, + jsonHeaders(), + ) + } + + val failure = assertFailsWith { + ClaudeApiClient(HttpClient(engine), secretStore()).analyzeImage(byteArrayOf(1)) + } + + assertEquals( + "Your credit balance is too low to access the Anthropic API.", + failure.message, + ) + } + + @Test + fun `an unparseable error falls back to a generic message`() = runTest { + val engine = MockEngine { + respond(ByteReadChannel("not json"), HttpStatusCode.BadRequest, jsonHeaders()) + } + + val failure = assertFailsWith { + ClaudeApiClient(HttpClient(engine), secretStore()).analyzeImage(byteArrayOf(1)) + } + + assertTrue(failure.message.orEmpty().contains("400"), "lost status: ${failure.message}") + } +} diff --git a/shared/src/commonMain/kotlin/com/github/worn/data/source/remote/ClaudeApiClient.kt b/shared/src/commonMain/kotlin/com/github/worn/data/source/remote/ClaudeApiClient.kt index 61092d3..5b02e74 100644 --- a/shared/src/commonMain/kotlin/com/github/worn/data/source/remote/ClaudeApiClient.kt +++ b/shared/src/commonMain/kotlin/com/github/worn/data/source/remote/ClaudeApiClient.kt @@ -27,7 +27,13 @@ class ClaudeApiClient( private val httpClient: HttpClient, private val secretStore: SecretStore, ) { - private val json = Json { ignoreUnknownKeys = true } + // encodeDefaults: the image source's `type`/`media_type` are defaults the API requires. + // explicitNulls: content blocks are a union, so unused members are omitted rather than nulled. + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + explicitNulls = false + } suspend fun analyzeImage(imageBytes: ByteArray): AiAnalysisResult { val responseText = sendRequest( @@ -164,7 +170,13 @@ class ClaudeApiClient( val responseBody = response.bodyAsText() if (!response.status.isSuccess()) { + println("[$LOG_TAG] HTTP ${response.status.value} body=${responseBody.take(BODY_PREVIEW_LEN)}") + val apiMessage = parseErrorMessage(responseBody) val userMessage = when (response.status.value) { + // 400 covers billing ("credit balance is too low") and malformed requests alike. + // Both are actionable and neither is fixed by retrying, so relay what the API said. + HTTP_BAD_REQUEST -> apiMessage + ?: "Claude rejected the request (400). Please try again." HTTP_UNAUTHORIZED -> "Invalid API key. Check your key in Settings." HTTP_TOO_MANY_REQUESTS -> "Too many requests. Please wait and try again." in HTTP_SERVER_ERROR_RANGE -> "Claude service error. Please try again later." @@ -180,11 +192,20 @@ class ClaudeApiClient( ?: error("No text content in Claude response") } + /** Anthropic errors carry a human-readable reason; fall back to the status when absent. */ + private fun parseErrorMessage(body: String): String? = + runCatching { json.decodeFromString(body).error.message } + .getOrNull() + ?.takeIf { it.isNotBlank() } + companion object { + private const val LOG_TAG = "ClaudeApi" + private const val BODY_PREVIEW_LEN = 300 private const val API_URL = "https://api.anthropic.com/v1/messages" private const val API_VERSION = "2023-06-01" - private const val MODEL = "claude-sonnet-4-20250514" + private const val MODEL = "claude-sonnet-5" private const val MAX_TOKENS = 1024 + private const val HTTP_BAD_REQUEST = 400 private const val HTTP_UNAUTHORIZED = 401 private const val HTTP_TOO_MANY_REQUESTS = 429 private val HTTP_SERVER_ERROR_RANGE = 500..599 diff --git a/shared/src/commonMain/kotlin/com/github/worn/data/source/remote/ClaudeApiModels.kt b/shared/src/commonMain/kotlin/com/github/worn/data/source/remote/ClaudeApiModels.kt index 3a7b537..50499a3 100644 --- a/shared/src/commonMain/kotlin/com/github/worn/data/source/remote/ClaudeApiModels.kt +++ b/shared/src/commonMain/kotlin/com/github/worn/data/source/remote/ClaudeApiModels.kt @@ -9,6 +9,17 @@ internal data class ClaudeRequest( @SerialName("max_tokens") val maxTokens: Int, val system: String, val messages: List, + val thinking: ClaudeThinking = ClaudeThinking(), +) + +/** + * Thinking is on by default from Claude Sonnet 5 onward, and `max_tokens` caps thinking *plus* + * response text. These calls parse the reply as JSON, so thinking is disabled to keep the whole + * budget available for the answer. + */ +@Serializable +internal data class ClaudeThinking( + val type: String = "disabled", ) @Serializable