Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<IllegalStateException> {
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<IllegalStateException> {
ClaudeApiClient(HttpClient(engine), secretStore()).analyzeImage(byteArrayOf(1))
}

assertTrue(failure.message.orEmpty().contains("400"), "lost status: ${failure.message}")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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."
Expand All @@ -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<ClaudeErrorResponse>(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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@ internal data class ClaudeRequest(
@SerialName("max_tokens") val maxTokens: Int,
val system: String,
val messages: List<ClaudeMessage>,
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
Expand Down
Loading