From 057168ba83d57f25e64aebddf9056ef1770af4e5 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Mon, 27 Jul 2026 18:53:50 -0300 Subject: [PATCH 1/2] fix: send a Claude request the API accepts Three defects made every AI request invalid on the wire: - Json defaults to encodeDefaults = false, so ClaudeImageSource's `type` and `media_type` were dropped and image blocks went out as bare {"data": "..."}. explicitNulls is disabled alongside it so the unused member of a content-block union is omitted rather than sent as null. - claude-sonnet-4-20250514 was retired on 2026-06-15, so the model no longer resolves. Move to claude-sonnet-5. - Thinking is on by default from Sonnet 5 onward and shares the max_tokens budget with the response. These calls parse the reply as JSON, so a truncated body breaks decoding; disable thinking to keep the full 1024 tokens for the answer. A live credit-balance failure masked all three, so they surface only once billing is resolved. Tests assert the serialised request shape. Co-Authored-By: Claude Opus 5 (1M context) --- .../github/worn/remote/ClaudeApiClientTest.kt | 80 +++++++++++++++++++ .../data/source/remote/ClaudeApiClient.kt | 10 ++- .../data/source/remote/ClaudeApiModels.kt | 11 +++ 3 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 shared/src/androidHostTest/kotlin/com/github/worn/remote/ClaudeApiClientTest.kt 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..2e00b5d --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/github/worn/remote/ClaudeApiClientTest.kt @@ -0,0 +1,80 @@ +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.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") + } +} 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..56c9d43 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( @@ -183,7 +189,7 @@ class ClaudeApiClient( companion object { 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_UNAUTHORIZED = 401 private const val HTTP_TOO_MANY_REQUESTS = 429 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 From 3a8c480a1e34176d315d42a02aae52f730f69df1 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Mon, 27 Jul 2026 18:54:29 -0300 Subject: [PATCH 2/2] fix: surface the reason Claude rejected a request A 400 fell into the catch-all branch and was reported as "Unexpected error (400). Please try again.", which is misleading: the most common cause is an exhausted credit balance, and no amount of retrying fixes it. The user is told to do the one thing that cannot work. Relay the API's own message for 400 instead. It is written for end users ("Your credit balance is too low to access the Anthropic API. Please go to Plans & Billing...") and covers malformed requests too. This puts the existing but unused ClaudeErrorResponse to work, and falls back to the status code when the body will not parse. Also log the error body, mirroring YouCamApiClient.ensureSuccess, since nothing recorded why a request failed. The body carries no credentials. Note: relayed messages are English regardless of app locale, unlike the hardcoded strings around them. Localising needs error *types* plumbed through to the UI rather than strings; left as follow-up. Co-Authored-By: Claude Opus 5 (1M context) --- .../github/worn/remote/ClaudeApiClientTest.kt | 38 +++++++++++++++++++ .../data/source/remote/ClaudeApiClient.kt | 15 ++++++++ 2 files changed, 53 insertions(+) diff --git a/shared/src/androidHostTest/kotlin/com/github/worn/remote/ClaudeApiClientTest.kt b/shared/src/androidHostTest/kotlin/com/github/worn/remote/ClaudeApiClientTest.kt index 2e00b5d..0e4f60c 100644 --- a/shared/src/androidHostTest/kotlin/com/github/worn/remote/ClaudeApiClientTest.kt +++ b/shared/src/androidHostTest/kotlin/com/github/worn/remote/ClaudeApiClientTest.kt @@ -12,6 +12,8 @@ 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 @@ -77,4 +79,40 @@ class ClaudeApiClientTest { 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 56c9d43..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 @@ -170,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." @@ -186,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-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