16
16
17
17
package com.xemantic.ai.anthropic
18
18
19
+ import com.xemantic.ai.anthropic.content.Content
19
20
import com.xemantic.ai.anthropic.content.ToolUse
20
21
import com.xemantic.ai.anthropic.cost.CostCollector
21
22
import com.xemantic.ai.anthropic.cost.CostWithUsage
@@ -25,13 +26,16 @@ import com.xemantic.ai.anthropic.event.Event
25
26
import com.xemantic.ai.anthropic.json.anthropicJson
26
27
import com.xemantic.ai.anthropic.message.MessageRequest
27
28
import com.xemantic.ai.anthropic.message.MessageResponse
29
+ import com.xemantic.ai.anthropic.tool.Tool
30
+ import com.xemantic.ai.anthropic.usage.Usage
28
31
import io.ktor.client.*
29
32
import io.ktor.client.call.*
30
33
import io.ktor.client.plugins.*
31
34
import io.ktor.client.plugins.contentnegotiation.*
32
35
import io.ktor.client.plugins.logging.*
33
36
import io.ktor.client.plugins.sse.*
34
37
import io.ktor.client.request.*
38
+ import io.ktor.client.statement.*
35
39
import io.ktor.http.*
36
40
import io.ktor.serialization.kotlinx.json.*
37
41
import kotlinx.coroutines.flow.Flow
@@ -119,19 +123,35 @@ class Anthropic internal constructor(
119
123
120
124
private val client = HttpClient {
121
125
122
- val retriableResponses = setOf< HttpStatusCode > (
126
+ val retriableResponses = setOf (
123
127
HttpStatusCode .RequestTimeout ,
124
128
HttpStatusCode .Conflict ,
125
129
HttpStatusCode .TooManyRequests ,
126
130
HttpStatusCode .InternalServerError
127
131
)
128
132
133
+ // declaration order matters :(
134
+ install(SSE )
135
+
136
+ HttpResponseValidator {
137
+ validateResponse { response ->
138
+ if (response.status != HttpStatusCode .OK
139
+ && ! (response.status in retriableResponses || response.status.value >= 500 )) {
140
+ val bytes = response.readRawBytes()
141
+ val errorString = String (bytes)
142
+ val errorResponse = anthropicJson.decodeFromString<ErrorResponse >(errorString)
143
+ throw AnthropicApiException (
144
+ error = errorResponse.error,
145
+ httpStatusCode = response.status
146
+ )
147
+ }
148
+ }
149
+ }
150
+
129
151
install(ContentNegotiation ) {
130
152
json(anthropicJson)
131
153
}
132
154
133
- install(SSE )
134
-
135
155
if (logLevel != LogLevel .NONE ) {
136
156
install(Logging ) {
137
157
level = logLevel
@@ -181,28 +201,16 @@ class Anthropic internal constructor(
181
201
}
182
202
val response = apiResponse.body<Response >()
183
203
when (response) {
184
- is MessageResponse -> response.apply {
185
- resolvedModel = anthropicModel
186
- costCollector + = costWithUsage
187
-
188
- val toolMap = request.tools?.associateBy { it.name } ? : emptyMap()
189
- content.filterIsInstance<ToolUse >().forEach { toolUse ->
190
- val tool = toolMap[toolUse.name]
191
- if (tool != null ) {
192
- toolUse.tool = tool
193
- } else {
194
- // Sometimes it happens that Claude is sending non-defined tool name in tool use
195
- // TODO in the future it should go to the stderr
196
- println (" Error!!! Unexpected tool use: ${toolUse.name} " )
197
- }
198
- }
204
+ is MessageResponse -> {
205
+ val toolMap = request.toolMap
206
+ response.resolvedModel = response.anthropicModel
207
+ response.content.resolveTools(toolMap)
208
+ costCollector + = response.costWithUsage
199
209
}
200
-
201
- is ErrorResponse -> throw AnthropicApiException (
210
+ is ErrorResponse -> throw AnthropicApiException ( // technically, this should be handled by the validator
202
211
error = response.error,
203
212
httpStatusCode = apiResponse.status
204
213
)
205
-
206
214
else -> throw RuntimeException (
207
215
" Unsupported response: $response "
208
216
) // should never happen
@@ -221,27 +229,50 @@ class Anthropic internal constructor(
221
229
stream = true
222
230
}.build()
223
231
224
- client.sse(
225
- urlString = " /v1/messages" ,
226
- request = {
227
- method = HttpMethod .Post
228
- contentType(ContentType .Application .Json )
229
- setBody(request)
230
- }
231
- ) {
232
- incoming
233
- .map { it.data }
234
- .filterNotNull()
235
- .map { anthropicJson.decodeFromString<Event >(it) }
236
- .collect { event ->
237
- // TODO we need better way of handling subsequent deltas with usage
238
- if (event is Event .MessageStart ) {
239
- // TODO more rules are needed here
240
- // costCollector += usageWithCost
241
- // updateUsage(event.message)
242
- }
243
- emit(event)
232
+ try {
233
+ client.sse(
234
+ urlString = " /v1/messages" ,
235
+ request = {
236
+ method = HttpMethod .Post
237
+ contentType(ContentType .Application .Json )
238
+ setBody(request)
244
239
}
240
+ ) {
241
+ var usage = Usage .ZERO
242
+ var resolvedModel: AnthropicModel ? = null
243
+ incoming
244
+ .map { it.data }
245
+ .filterNotNull()
246
+ .map { anthropicJson.decodeFromString<Event >(it) }
247
+ .collect { event ->
248
+ when (event) {
249
+ is Event .MessageDelta -> {
250
+ usage + = Usage {
251
+ inputTokens = 0
252
+ outputTokens = event.usage.outputTokens
253
+ }
254
+ }
255
+ is Event .MessageStart -> {
256
+ resolvedModel = event.message.anthropicModel
257
+ usage + = event.message.usage
258
+ }
259
+ is Event .MessageStop -> {
260
+ event.toolMap = request.toolMap
261
+ event.resolvedModel = resolvedModel!!
262
+ val costWithUsage = CostWithUsage (
263
+ cost = resolvedModel.cost * usage,
264
+ usage = usage
265
+ )
266
+ costCollector + = costWithUsage
267
+ }
268
+ else -> { /* do nothing */ }
269
+ }
270
+ emit(event)
271
+ }
272
+ }
273
+ } catch (e: SSEClientException ) {
274
+ if (e.cause is AnthropicApiException ) throw e.cause!!
275
+ throw e
245
276
}
246
277
}
247
278
@@ -270,3 +301,18 @@ class AnthropicConfigException(
270
301
) : AnthropicException(
271
302
msg, cause
272
303
)
304
+
305
+ internal fun List<Content>.resolveTools (toolMap : Map <String , Tool >) {
306
+ filterIsInstance<ToolUse >().forEach { toolUse ->
307
+ val tool = toolMap[toolUse.name]
308
+ if (tool != null ) {
309
+ toolUse.tool = tool
310
+ } else {
311
+ // Sometimes it happens that Claude is sending non-defined tool name in tool use
312
+ // TODO in the future it should go to the stderr
313
+ println (" Error!!! Unexpected tool use: ${toolUse.name} " )
314
+ }
315
+ }
316
+ }
317
+
318
+ private val MessageRequest .toolMap: Map <String , Tool > get() = tools?.associateBy { it.name } ? : emptyMap()
0 commit comments