Skip to content

Commit 75bad75

Browse files
committed
✨ [AI]: Add Anthropic Messages API support and non-streaming mode
- Add Anthropic-compatible provider with custom auth configuration - Support non-streaming mode (stream=false) in useChat - Unified error parsing for OpenAI and Anthropic APIs - Enhanced response parsing for tool calls and reasoning blocks - Add AnthropicException for proper error handling - Improve HTTP error response handling - Update documentation with Anthropic API support
1 parent 9c4ccb6 commit 75bad75

10 files changed

Lines changed: 438 additions & 85 deletions

File tree

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ Note: All `use` functions also have the signature of `remember`. If you prefer C
152152
153153
### AI Module
154154

155-
A separate AI module providing hooks for AI chat completions and structured data generation with OpenAI-compatible APIs.
155+
A separate AI module providing hooks for AI chat completions and structured data generation with OpenAI-compatible APIs and Anthropic Messages API.
156156

157157
**Add AI module dependency:**
158158
```kotlin
@@ -161,11 +161,12 @@ implementation("xyz.junerver.compose:ai:<latest_release>")
161161

162162
| hook name | description |
163163
| --------- | ----------- |
164-
| [useChat](https://github.com/junerver/ComposeHooks/blob/master/app/src/commonMain/kotlin/xyz/junerver/composehooks/example/UseChatExample.kt) | A hook for managing chat conversations with OpenAI-compatible APIs, supporting streaming responses with typewriter effect. |
164+
| [useChat](https://github.com/junerver/ComposeHooks/blob/master/app/src/commonMain/kotlin/xyz/junerver/composehooks/example/UseChatExample.kt) | A hook for managing chat conversations with OpenAI-compatible APIs and Anthropic Messages API, supporting streaming responses with typewriter effect. |
165165
| [useGenerateObject](https://github.com/junerver/ComposeHooks/blob/master/app/src/commonMain/kotlin/xyz/junerver/composehooks/example/UseGenerateObjectExample.kt) | A hook for generating structured data objects from AI responses, supporting multimodal input (text + images). |
166166

167167
**Features:**
168168
- Streaming responses (SSE) with real-time typewriter effect
169+
- Non-streaming mode support (`stream = false`)
169170
- Multimodal input support (text, images, files)
170171
- Structured data generation with type safety
171172
- Message state management

README.zh-CN.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ implementation("xyz.junerver.compose:hooks2:<latest_release>")
159159
160160
### AI 模块
161161

162-
独立的 AI 模块,提供与 OpenAI 兼容 API 进行 AI 聊天和结构化数据生成的 Hook。
162+
独立的 AI 模块,提供与 OpenAI 兼容 API 和 Anthropic Messages API 进行 AI 聊天与结构化数据生成的 Hook。
163163

164164
**添加 AI 模块依赖:**
165165
```kotlin
@@ -168,11 +168,12 @@ implementation("xyz.junerver.compose:hai:<latest_release>")
168168

169169
| Hook 名称 | 描述 |
170170
| --------- | ---- |
171-
| [useChat](https://github.com/junerver/ComposeHooks/blob/master/app/src/commonMain/kotlin/xyz/junerver/composehooks/example/UseChatExample.kt) | 用于管理与 OpenAI 兼容 API 聊天对话的 Hook,支持流式响应的打字机效果。 |
171+
| [useChat](https://github.com/junerver/ComposeHooks/blob/master/app/src/commonMain/kotlin/xyz/junerver/composehooks/example/UseChatExample.kt) | 用于管理与 OpenAI 兼容 API 和 Anthropic Messages API 聊天对话的 Hook,支持流式响应的打字机效果。 |
172172
| [useGenerateObject](https://github.com/junerver/ComposeHooks/blob/master/app/src/commonMain/kotlin/xyz/junerver/composehooks/example/UseGenerateObjectExample.kt) | 用于从 AI 响应生成结构化数据对象的 Hook,支持多模态输入(文本 + 图片)。 |
173173

174174
**功能特性:**
175175
- 流式响应 (SSE),实时打字机效果
176+
- 支持非流式模式(`stream = false`
176177
- 多模态输入支持(文本、图片、文件)
177178
- 类型安全的结构化数据生成
178179
- 消息状态管理

ai/src/commonMain/kotlin/xyz/junerver/compose/ai/http/KtorHttpEngine.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ class KtorHttpEngine(
9494
request.body?.let { setBody(it) }
9595
}.execute { response ->
9696
if (!response.status.isSuccess()) {
97-
val errorBody = response.bodyAsChannel().readUTF8Line() ?: "Unknown error"
97+
val errorBody = response.bodyAsText()
9898
emit(SseEvent.Error(Exception("HTTP ${response.status.value}: $errorBody")))
9999
return@execute
100100
}

ai/src/commonMain/kotlin/xyz/junerver/compose/ai/usechat/AnthropicModels.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,14 @@ internal data class AnthropicError(
334334
val message: String,
335335
)
336336

337+
/**
338+
* Exception representing an Anthropic API error.
339+
*/
340+
class AnthropicException(
341+
val errorMessage: String,
342+
val errorType: String,
343+
) : Exception(errorMessage)
344+
337345
// endregion
338346

339347
// region Internal Helpers

ai/src/commonMain/kotlin/xyz/junerver/compose/ai/usechat/ChatClient.kt

Lines changed: 48 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -77,21 +77,9 @@ internal class ChatClient(private val options: ChatOptions) {
7777
}
7878
is SseEvent.Complete -> emit(StreamEvent.Done)
7979
is SseEvent.Error -> {
80-
val errorBody = event.error.message ?: "Unknown error"
81-
try {
82-
val errorResponse = json.decodeFromString<OpenAIErrorResponse>(errorBody)
83-
emit(
84-
StreamEvent.Error(
85-
OpenAIException(
86-
errorMessage = errorResponse.error.message,
87-
errorType = errorResponse.error.type,
88-
errorCode = errorResponse.error.code,
89-
),
90-
),
91-
)
92-
} catch (e: Exception) {
93-
emit(StreamEvent.Error(event.error))
94-
}
80+
val parsed = parseAnyProviderError(event.error.message)
81+
?: event.error
82+
emit(StreamEvent.Error(parsed))
9583
}
9684
}
9785
}
@@ -103,7 +91,7 @@ internal class ChatClient(private val options: ChatOptions) {
10391
* @param messages The list of messages to send
10492
* @return The complete assistant message
10593
*/
106-
suspend fun chat(messages: List<ChatMessage>): AssistantMessage {
94+
suspend fun chat(messages: List<ChatMessage>): ChatResponseResult {
10795
val requestBody = options.buildRequestBody(messages, stream = false)
10896
val headers = options.buildAuthHeaders() + options.headers
10997

@@ -117,26 +105,15 @@ internal class ChatClient(private val options: ChatOptions) {
117105
val result = engine.execute(request)
118106

119107
if (result.statusCode !in 200..299) {
120-
try {
121-
val errorResponse = json.decodeFromString<OpenAIErrorResponse>(result.body)
122-
throw OpenAIException(
123-
errorMessage = errorResponse.error.message,
124-
errorType = errorResponse.error.type,
125-
errorCode = errorResponse.error.code,
126-
)
127-
} catch (e: OpenAIException) {
128-
throw e
129-
} catch (e: Exception) {
130-
throw Exception("HTTP ${result.statusCode}: ${result.body}")
131-
}
108+
throw parseAnyProviderError(result.body, statusCode = result.statusCode)
109+
?: Exception("HTTP ${result.statusCode}: ${result.body}")
132110
}
133111

134112
val responseBody = result.body
135113
if (responseBody.isEmpty()) throw Exception("Empty response")
136114

137115
// Use provider-specific response parsing
138-
val parseResult = options.provider.parseResponse(responseBody)
139-
return parseResult.message
116+
return options.provider.parseResponse(responseBody)
140117
}
141118

142119
/**
@@ -145,4 +122,45 @@ internal class ChatClient(private val options: ChatOptions) {
145122
fun close() {
146123
engine.close()
147124
}
125+
126+
private fun parseAnyProviderError(raw: String?, statusCode: Int? = null): Throwable? {
127+
val candidate = extractJsonCandidate(raw ?: return null) ?: raw
128+
129+
parseAnthropicError(candidate)?.let { return it }
130+
parseOpenAIError(candidate)?.let { return it }
131+
132+
return if (statusCode != null) {
133+
Exception("HTTP $statusCode: $candidate")
134+
} else {
135+
null
136+
}
137+
}
138+
139+
private fun parseOpenAIError(candidate: String): OpenAIException? = try {
140+
val errorResponse = json.decodeFromString<OpenAIErrorResponse>(candidate)
141+
OpenAIException(
142+
errorMessage = errorResponse.error.message,
143+
errorType = errorResponse.error.type,
144+
errorCode = errorResponse.error.code,
145+
)
146+
} catch (e: Exception) {
147+
null
148+
}
149+
150+
private fun parseAnthropicError(candidate: String): AnthropicException? = try {
151+
val errorResponse = json.decodeFromString<AnthropicErrorResponse>(candidate)
152+
AnthropicException(
153+
errorMessage = errorResponse.error.message,
154+
errorType = errorResponse.error.type,
155+
)
156+
} catch (e: Exception) {
157+
null
158+
}
159+
160+
private fun extractJsonCandidate(text: String): String? {
161+
val start = text.indexOf('{')
162+
val end = text.lastIndexOf('}')
163+
if (start < 0 || end <= start) return null
164+
return text.substring(start, end + 1).trim()
165+
}
148166
}

ai/src/commonMain/kotlin/xyz/junerver/compose/ai/usechat/ChatProvider.kt

Lines changed: 128 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package xyz.junerver.compose.ai.usechat
22

33
import kotlinx.serialization.json.Json
4+
import kotlinx.serialization.json.JsonObject
5+
import kotlinx.serialization.json.buildJsonObject
6+
import kotlinx.serialization.json.jsonObject
47

58
/*
69
Description: Chat provider abstraction for multi-vendor support
@@ -191,10 +194,36 @@ sealed class Providers : ChatProvider {
191194
val response = json.decodeFromString<ChatCompletionResponse>(body)
192195
val choice = response.choices.firstOrNull()
193196
?: throw Exception("No choices in response")
197+
val finishReason = choice.finishReason?.let { FinishReason.fromString(it) }
198+
199+
val contentParts = buildList<AssistantContentPart> {
200+
choice.message.content?.takeIf { it.isNotEmpty() }?.let { add(TextPart(it)) }
201+
202+
choice.message.toolCalls.orEmpty().forEach { tc ->
203+
val args: JsonObject = try {
204+
json.parseToJsonElement(tc.function.arguments).jsonObject
205+
} catch (e: Exception) {
206+
buildJsonObject { }
207+
}
208+
add(
209+
ToolCallPart(
210+
toolCallId = tc.id,
211+
toolName = tc.function.name,
212+
args = args,
213+
),
214+
)
215+
}
216+
}.ifEmpty { listOf(TextPart("")) }
217+
194218
return ChatResponseResult(
195-
message = assistantMessage(text = choice.message.content ?: ""),
219+
message = assistantMessage(
220+
content = contentParts,
221+
model = response.model,
222+
usage = response.usage,
223+
finishReason = finishReason,
224+
),
196225
usage = response.usage,
197-
finishReason = choice.finishReason?.let { FinishReason.fromString(it) },
226+
finishReason = finishReason,
198227
)
199228
}
200229
}
@@ -401,17 +430,107 @@ sealed class Providers : ChatProvider {
401430

402431
override fun parseResponse(body: String): ChatResponseResult {
403432
val response = json.decodeFromString<AnthropicResponse>(body)
404-
val content = response.content.firstOrNull()?.text ?: ""
433+
val usage = ChatUsage(
434+
promptTokens = response.usage.inputTokens,
435+
completionTokens = response.usage.outputTokens,
436+
totalTokens = response.usage.inputTokens + response.usage.outputTokens,
437+
)
438+
val finishReason = response.stopReason?.let { FinishReason.fromString(it) }
439+
440+
val parts = buildList<AssistantContentPart> {
441+
response.content.forEach { block ->
442+
when (block.type) {
443+
"text" -> block.text?.takeIf { it.isNotEmpty() }?.let { add(TextPart(it)) }
444+
"thinking" -> block.thinking?.takeIf { it.isNotEmpty() }?.let { add(ReasoningPart(it)) }
445+
"tool_use" -> {
446+
val id = block.id
447+
val name = block.name
448+
val input = block.input
449+
if (id != null && name != null && input != null) {
450+
add(
451+
ToolCallPart(
452+
toolCallId = id,
453+
toolName = name,
454+
args = input,
455+
),
456+
)
457+
}
458+
}
459+
}
460+
}
461+
}.ifEmpty { listOf(TextPart("")) }
462+
405463
return ChatResponseResult(
406-
message = assistantMessage(text = content),
407-
usage = ChatUsage(
408-
promptTokens = response.usage.inputTokens,
409-
completionTokens = response.usage.outputTokens,
410-
totalTokens = response.usage.inputTokens + response.usage.outputTokens,
464+
message = assistantMessage(
465+
content = parts,
466+
model = response.model,
467+
usage = usage,
468+
finishReason = finishReason,
411469
),
412-
finishReason = response.stopReason?.let { FinishReason.fromString(it) },
470+
usage = usage,
471+
finishReason = finishReason,
472+
)
473+
}
474+
}
475+
476+
/**
477+
* Anthropic-compatible Messages API provider.
478+
*
479+
* Use this for vendors that implement Anthropic `/v1/messages` but with custom:
480+
* - `baseUrl`
481+
* - auth header name/prefix (e.g. `Authorization: Bearer ...`)
482+
* - optional `anthropic-version` requirement
483+
*/
484+
data class AnthropicCompatible(
485+
override val baseUrl: String,
486+
override val defaultModel: String,
487+
override val apiKey: String = "",
488+
override val name: String = "AnthropicCompatible",
489+
val apiKeyHeader: String = "x-api-key",
490+
val apiKeyPrefix: String? = null,
491+
val anthropicVersion: String? = "2023-06-01",
492+
override val chatEndpoint: String = "/v1/messages",
493+
) : Providers() {
494+
override fun buildAuthHeaders(): Map<String, String> = buildMap {
495+
if (apiKey.isNotBlank()) {
496+
val value = apiKeyPrefix?.let { "$it $apiKey" } ?: apiKey
497+
put(apiKeyHeader, value)
498+
}
499+
if (!anthropicVersion.isNullOrBlank()) {
500+
put("anthropic-version", anthropicVersion)
501+
}
502+
}
503+
504+
override fun buildRequestBody(
505+
messages: List<ChatMessage>,
506+
model: String,
507+
stream: Boolean,
508+
temperature: Float?,
509+
maxTokens: Int?,
510+
systemPrompt: String?,
511+
): String {
512+
val request = AnthropicRequest(
513+
model = model,
514+
messages = messages.toAnthropicMessages(),
515+
stream = stream,
516+
system = systemPrompt,
517+
temperature = temperature,
518+
maxTokens = maxTokens ?: 4096,
413519
)
520+
return json.encodeToString(AnthropicRequest.serializer(), request)
414521
}
522+
523+
override fun parseStreamLine(line: String): StreamEvent? = Anthropic(
524+
apiKey = apiKey,
525+
baseUrl = baseUrl,
526+
defaultModel = defaultModel,
527+
).parseStreamLine(line)
528+
529+
override fun parseResponse(body: String): ChatResponseResult = Anthropic(
530+
apiKey = apiKey,
531+
baseUrl = baseUrl,
532+
defaultModel = defaultModel,
533+
).parseResponse(body)
415534
}
416535

417536
// endregion

0 commit comments

Comments
 (0)