|
| 1 | +import { Message, type MessageCreateParams, type MessageStreamParams } from '@anthropic-ai/sdk/resources'; |
| 2 | +import { |
| 3 | + MessageStreamEvent, |
| 4 | + RawMessageStreamEvent, |
| 5 | + type ContentBlockParam, |
| 6 | + type InputJSONDelta, |
| 7 | + type RawContentBlockDeltaEvent, |
| 8 | + type RawContentBlockStartEvent, |
| 9 | + type RawMessageStartEvent, |
| 10 | + type TextBlock, |
| 11 | + type TextBlockParam, |
| 12 | + type TextDelta, |
| 13 | + type ThinkingBlock, |
| 14 | + type ToolResultBlockParam, |
| 15 | + type ToolUseBlock |
| 16 | +} from '@anthropic-ai/sdk/resources/messages'; |
| 17 | +import { |
| 18 | + EvaluationProxyAPIRequest, |
| 19 | + LLMMessage, |
| 20 | + LLMToolCall, |
| 21 | + LLMToolDefinition, |
| 22 | +} from '../../types'; |
| 23 | +import { CanonicalEvaluationStrategy } from '../canonical'; |
| 24 | + |
| 25 | +type AnthropicCreateAPIResponsesType = Message | RawMessageStreamEvent; |
| 26 | + |
| 27 | +type AnthropicAPIRequestsType = MessageCreateParams |
| 28 | +type AnthropicAPIResponsesType = |
| 29 | + | AnthropicCreateAPIResponsesType |
| 30 | + | MessageStreamParams; |
| 31 | + |
| 32 | +export class ClaudeCanonicalEvaluationStrategy |
| 33 | + implements |
| 34 | + CanonicalEvaluationStrategy< |
| 35 | + AnthropicAPIRequestsType, |
| 36 | + AnthropicAPIResponsesType |
| 37 | + > { |
| 38 | + async convertToQualifireEvaluationRequest( |
| 39 | + request: AnthropicAPIRequestsType, |
| 40 | + response: AnthropicAPIResponsesType |
| 41 | + ): Promise<EvaluationProxyAPIRequest> { |
| 42 | + const { |
| 43 | + messages: requestMessages, |
| 44 | + available_tools: requestAvailableTools, |
| 45 | + } = this.convertRequest(request); |
| 46 | + |
| 47 | + const messages: LLMMessage[] = requestMessages || []; |
| 48 | + const availableTools: LLMToolDefinition[] = requestAvailableTools || []; |
| 49 | + |
| 50 | + // Avoid undefined response |
| 51 | + if (!response) { |
| 52 | + return { |
| 53 | + messages, |
| 54 | + available_tools: availableTools, |
| 55 | + }; |
| 56 | + } |
| 57 | + |
| 58 | + // Check if response is streaming or non-streaming |
| 59 | + if (Array.isArray(response)) { |
| 60 | + const streamingResultMessages = await this.handleStreaming(response as Array<MessageStreamEvent>); |
| 61 | + messages.push(...streamingResultMessages); |
| 62 | + } else { |
| 63 | + const nonStreamingResultMessages = await this.handleNonStreamingResponse( |
| 64 | + response |
| 65 | + ); |
| 66 | + messages.push(...nonStreamingResultMessages); |
| 67 | + } |
| 68 | + |
| 69 | + return { |
| 70 | + messages, |
| 71 | + available_tools: availableTools, |
| 72 | + }; |
| 73 | + } |
| 74 | + |
| 75 | + convertRequest(request: any): EvaluationProxyAPIRequest { |
| 76 | + const messages: LLMMessage[] = []; |
| 77 | + const availableTools: LLMToolDefinition[] = []; |
| 78 | + |
| 79 | + // Handle Claude system message first (if present) |
| 80 | + if (request?.system) { |
| 81 | + messages.push({ |
| 82 | + role: 'system', |
| 83 | + content: request.system as string, |
| 84 | + }); |
| 85 | + } |
| 86 | + |
| 87 | + // Handle Claude request messages |
| 88 | + if (request?.messages) { |
| 89 | + messages.push( |
| 90 | + ...this.convertClaudeMessagesToLLMMessages(request.messages as Array<Message>) |
| 91 | + ); |
| 92 | + } |
| 93 | + |
| 94 | + // Handle tools |
| 95 | + if (request?.tools) { |
| 96 | + for (const tool of request.tools) { |
| 97 | + availableTools.push({ |
| 98 | + name: tool.name, |
| 99 | + description: tool.description, |
| 100 | + parameters: tool.input_schema?.properties || {}, |
| 101 | + }); |
| 102 | + } |
| 103 | + } |
| 104 | + |
| 105 | + return { |
| 106 | + messages, |
| 107 | + available_tools: availableTools, |
| 108 | + }; |
| 109 | + } |
| 110 | + |
| 111 | + private async handleStreaming(response: Array<MessageStreamEvent>): Promise<LLMMessage[]> { |
| 112 | + const messages: LLMMessage[] = []; |
| 113 | + |
| 114 | + let role: string | undefined; |
| 115 | + let accumulatedContent: string[] = []; |
| 116 | + let accumulatedToolName: string | undefined; |
| 117 | + let accumulatedToolId: string | undefined; |
| 118 | + let accumulatedToolInput: string[] = []; |
| 119 | + for (const responseEvent of response) { |
| 120 | + switch (responseEvent.type) { |
| 121 | + case 'message_start': |
| 122 | + const rawMessageStartEvent = responseEvent as RawMessageStartEvent; |
| 123 | + role = rawMessageStartEvent.message.role; |
| 124 | + accumulatedContent = []; |
| 125 | + accumulatedToolName = undefined; |
| 126 | + accumulatedToolId = undefined; |
| 127 | + accumulatedToolInput = []; |
| 128 | + break; |
| 129 | + case 'content_block_start': |
| 130 | + const rawContentBlockStartEvent = responseEvent as RawContentBlockStartEvent; |
| 131 | + switch (rawContentBlockStartEvent.content_block.type) { |
| 132 | + case 'text': |
| 133 | + const textBlock = rawContentBlockStartEvent.content_block as TextBlock; |
| 134 | + accumulatedContent.push(textBlock.text) |
| 135 | + break; |
| 136 | + case 'tool_use': |
| 137 | + const toolUseBlock = rawContentBlockStartEvent.content_block as ToolUseBlock; |
| 138 | + accumulatedToolId = toolUseBlock.id |
| 139 | + accumulatedToolName = toolUseBlock.name |
| 140 | + accumulatedToolInput = [] |
| 141 | + break; |
| 142 | + case 'thinking': |
| 143 | + const thinkingBlock = rawContentBlockStartEvent.content_block as ThinkingBlock; |
| 144 | + accumulatedContent.push(thinkingBlock.thinking) |
| 145 | + break; |
| 146 | + default: |
| 147 | + console.debug(`Invalid content block type: ${responseEvent}`); |
| 148 | + } |
| 149 | + break; |
| 150 | + case 'content_block_delta': |
| 151 | + const rawContentBlockDeltaEvent = responseEvent as RawContentBlockDeltaEvent; |
| 152 | + switch (rawContentBlockDeltaEvent.delta.type) { |
| 153 | + case 'text_delta': |
| 154 | + const textDelta = rawContentBlockDeltaEvent.delta as TextDelta; |
| 155 | + accumulatedContent.push(textDelta.text) |
| 156 | + break; |
| 157 | + case 'input_json_delta': |
| 158 | + const inputJsonDelta = rawContentBlockDeltaEvent.delta as InputJSONDelta; |
| 159 | + accumulatedToolInput.push(inputJsonDelta.partial_json) |
| 160 | + break; |
| 161 | + default: |
| 162 | + console.debug(`Invalid delta type: ${rawContentBlockDeltaEvent}`); |
| 163 | + } |
| 164 | + break; |
| 165 | + case 'message_stop': |
| 166 | + let finalContent: string | undefined; |
| 167 | + if (accumulatedContent.length > 0) { |
| 168 | + finalContent = accumulatedContent.join('').trim(); |
| 169 | + } |
| 170 | + let finalTool: LLMToolCall | undefined; |
| 171 | + if (accumulatedToolName) { |
| 172 | + finalTool = { |
| 173 | + id: accumulatedToolId, |
| 174 | + name: accumulatedToolName, |
| 175 | + arguments: JSON.parse(accumulatedToolInput.join('')), |
| 176 | + }; |
| 177 | + }; |
| 178 | + if (!role) { |
| 179 | + console.debug(`role was not set`); |
| 180 | + continue; |
| 181 | + } |
| 182 | + messages.push({ |
| 183 | + role: role == 'model' ? 'assistant' : role, |
| 184 | + content: finalContent ?? undefined, |
| 185 | + tool_calls: finalTool ? [finalTool] : undefined, |
| 186 | + }); |
| 187 | + role = undefined; |
| 188 | + accumulatedContent = []; |
| 189 | + accumulatedToolName = undefined; |
| 190 | + accumulatedToolId = undefined; |
| 191 | + accumulatedToolInput = []; |
| 192 | + break; |
| 193 | + case 'content_block_stop': |
| 194 | + case 'message_delta': |
| 195 | + break; |
| 196 | + default: |
| 197 | + console.debug(`Invalid event: ${responseEvent}`); |
| 198 | + } |
| 199 | + } |
| 200 | + return messages; |
| 201 | + } |
| 202 | + |
| 203 | + private async handleNonStreamingResponse( |
| 204 | + response: any |
| 205 | + ): Promise<LLMMessage[]> { |
| 206 | + const messages: LLMMessage[] = []; |
| 207 | + |
| 208 | + if (response.role !== 'assistant') { |
| 209 | + throw new Error( |
| 210 | + `Response role must be 'assistant'. Make sure to use response |
| 211 | + from anthropic.messages.create() when not using streaming.`); |
| 212 | + } |
| 213 | + messages.push(...this.convertClaudeMessagesToLLMMessages([response] as Array<Message>)); |
| 214 | + |
| 215 | + return messages; |
| 216 | + } |
| 217 | + |
| 218 | + // Claude-specific function to convert Response API messages to LLM messages |
| 219 | + private convertClaudeMessagesToLLMMessages(messages: Array<Message>): LLMMessage[] { |
| 220 | + const extractedMessages: LLMMessage[] = []; |
| 221 | + |
| 222 | + for (const message of messages) { |
| 223 | + if (typeof message.content === 'string') { |
| 224 | + const llmMessage: LLMMessage = { |
| 225 | + role: message.role, |
| 226 | + content: message.content, |
| 227 | + }; |
| 228 | + extractedMessages.push(llmMessage); |
| 229 | + continue; |
| 230 | + } |
| 231 | + const aggregatedContent: string[] = []; |
| 232 | + const aggregatedToolCalls: LLMToolCall[] = []; |
| 233 | + let role: string = message.role; |
| 234 | + if (!message.content) { |
| 235 | + continue; |
| 236 | + } |
| 237 | + for (const part of (message.content as Array<ContentBlockParam>)) { |
| 238 | + switch (part.type) { |
| 239 | + case 'tool_use': |
| 240 | + const toolUseBlock = part as ToolUseBlock; |
| 241 | + aggregatedToolCalls.push({ |
| 242 | + name: toolUseBlock.name, |
| 243 | + arguments: toolUseBlock.input as Record<string, any>, |
| 244 | + id: toolUseBlock.id, |
| 245 | + }); |
| 246 | + break; |
| 247 | + case 'tool_result': |
| 248 | + role = 'tool'; // Claude expects 'user' role for tool results. But Qualifire treats tool as results as it is sent from 'tool' |
| 249 | + const toolResultBlock = part as ToolResultBlockParam; |
| 250 | + if (typeof toolResultBlock.content === 'string') { |
| 251 | + aggregatedContent.push(toolResultBlock.content) |
| 252 | + } else { |
| 253 | + (toolResultBlock.content as Array<ContentBlockParam>).filter(part => part.type === 'text').forEach(part => { |
| 254 | + const textPart = part as TextBlockParam; |
| 255 | + aggregatedContent.push(textPart.text) |
| 256 | + }) |
| 257 | + } |
| 258 | + break; |
| 259 | + case 'text': |
| 260 | + const textBlock = part as TextBlock; |
| 261 | + aggregatedContent.push(textBlock.text) |
| 262 | + break; |
| 263 | + default: |
| 264 | + console.debug( |
| 265 | + 'Invalid Claude output: message - ' + |
| 266 | + JSON.stringify(message) + |
| 267 | + ' part - ' + |
| 268 | + JSON.stringify(part) |
| 269 | + ); |
| 270 | + } |
| 271 | + } |
| 272 | + |
| 273 | + // If we accumulated aggregatedContent or aggregatedToolCalls, add the message |
| 274 | + if (aggregatedContent.length > 0 || aggregatedToolCalls.length > 0) { |
| 275 | + let accumulatedMessage: LLMMessage = { |
| 276 | + role, |
| 277 | + }; |
| 278 | + |
| 279 | + if (aggregatedContent.length > 0) { |
| 280 | + accumulatedMessage.content = aggregatedContent.join(''); |
| 281 | + } |
| 282 | + |
| 283 | + // Only add aggregatedToolCalls property for assistant messages |
| 284 | + if (aggregatedToolCalls.length > 0) { |
| 285 | + accumulatedMessage.tool_calls = aggregatedToolCalls; |
| 286 | + } |
| 287 | + extractedMessages.push(accumulatedMessage); |
| 288 | + } |
| 289 | + } |
| 290 | + return extractedMessages; |
| 291 | + } |
| 292 | +} |
0 commit comments