|
16 | 16 |
|
17 | 17 | package com.google.adk.models; |
18 | 18 |
|
| 19 | +import static java.nio.charset.StandardCharsets.UTF_8; |
| 20 | + |
19 | 21 | import com.anthropic.client.AnthropicClient; |
20 | 22 | import com.anthropic.models.messages.ContentBlock; |
21 | 23 | import com.anthropic.models.messages.ContentBlockParam; |
22 | 24 | import com.anthropic.models.messages.Message; |
23 | 25 | import com.anthropic.models.messages.MessageCreateParams; |
24 | 26 | import com.anthropic.models.messages.MessageParam; |
25 | 27 | import com.anthropic.models.messages.MessageParam.Role; |
| 28 | +import com.anthropic.models.messages.RedactedThinkingBlockParam; |
26 | 29 | import com.anthropic.models.messages.TextBlockParam; |
| 30 | +import com.anthropic.models.messages.ThinkingBlock; |
| 31 | +import com.anthropic.models.messages.ThinkingBlockParam; |
27 | 32 | import com.anthropic.models.messages.Tool; |
28 | 33 | import com.anthropic.models.messages.ToolChoice; |
29 | 34 | import com.anthropic.models.messages.ToolChoiceAuto; |
|
32 | 37 | import com.anthropic.models.messages.ToolUseBlockParam; |
33 | 38 | import com.fasterxml.jackson.core.type.TypeReference; |
34 | 39 | import com.google.adk.JsonBaseModel; |
| 40 | +import com.google.common.base.Utf8; |
35 | 41 | import com.google.common.collect.ImmutableList; |
36 | 42 | import com.google.common.collect.ImmutableMap; |
37 | 43 | import com.google.genai.types.*; |
|
44 | 50 | import java.util.Objects; |
45 | 51 | import java.util.Optional; |
46 | 52 | import java.util.stream.Collectors; |
| 53 | +import org.jspecify.annotations.Nullable; |
47 | 54 | import org.slf4j.Logger; |
48 | 55 | import org.slf4j.LoggerFactory; |
49 | 56 |
|
@@ -104,6 +111,8 @@ public Flowable<LlmResponse> generateContent(LlmRequest llmRequest, boolean stre |
104 | 111 | List<MessageParam> messages = |
105 | 112 | llmRequest.contents().stream() |
106 | 113 | .map(this::contentToAnthropicMessageParam) |
| 114 | + // Anthropic rejects empty messages, e.g. a turn whose parts were all dropped. |
| 115 | + .filter(message -> !message.content().asBlockParams().isEmpty()) |
107 | 116 | .collect(Collectors.toList()); |
108 | 117 |
|
109 | 118 | List<ToolUnion> tools = ImmutableList.of(); |
@@ -163,20 +172,85 @@ private Role toClaudeRole(String role) { |
163 | 172 | } |
164 | 173 |
|
165 | 174 | private MessageParam contentToAnthropicMessageParam(Content content) { |
166 | | - return MessageParam.builder() |
167 | | - .role(toClaudeRole(content.role().orElse(""))) |
168 | | - .contentOfBlockParams( |
169 | | - content.parts().orElse(ImmutableList.of()).stream() |
170 | | - .map(this::partToAnthropicMessageBlock) |
171 | | - .filter(Objects::nonNull) |
172 | | - .collect(Collectors.toList())) |
173 | | - .build(); |
| 175 | + Role role = toClaudeRole(content.role().orElse("")); |
| 176 | + List<Part> parts = content.parts().orElse(ImmutableList.of()); |
| 177 | + List<ContentBlockParam> blocks = |
| 178 | + parts.stream() |
| 179 | + .map(this::partToAnthropicMessageBlock) |
| 180 | + .filter(Objects::nonNull) |
| 181 | + .collect(Collectors.toList()); |
| 182 | + // Keep a user turn of only empty text as ".", or a last assistant turn would be prefill. |
| 183 | + if (blocks.isEmpty() |
| 184 | + && role.equals(Role.USER) |
| 185 | + && parts.stream().anyMatch(Claude::isEmptyTextPart)) { |
| 186 | + blocks = |
| 187 | + ImmutableList.of(ContentBlockParam.ofText(TextBlockParam.builder().text(".").build())); |
| 188 | + } |
| 189 | + return MessageParam.builder().role(role).contentOfBlockParams(blocks).build(); |
174 | 190 | } |
175 | 191 |
|
176 | | - private ContentBlockParam partToAnthropicMessageBlock(Part part) { |
177 | | - if (part.text().isPresent()) { |
178 | | - return ContentBlockParam.ofText(TextBlockParam.builder().text(part.text().get()).build()); |
179 | | - } else if (part.functionCall().isPresent()) { |
| 192 | + /** Whether a part has any of the fields ADK Python counts as content. */ |
| 193 | + private static boolean hasContentFields(Part part) { |
| 194 | + return part.functionCall().isPresent() |
| 195 | + || part.functionResponse().isPresent() |
| 196 | + || part.inlineData().isPresent() |
| 197 | + || part.fileData().isPresent() |
| 198 | + || part.executableCode().isPresent() |
| 199 | + || part.codeExecutionResult().isPresent() |
| 200 | + || part.toolCall().isPresent() |
| 201 | + || part.toolResponse().isPresent() |
| 202 | + || part.audioTranscription().isPresent(); |
| 203 | + } |
| 204 | + |
| 205 | + /** Whether a non-thought part carries a signature and nothing to send, as in ADK Python. */ |
| 206 | + private static boolean isContentFreeSignature(Part part) { |
| 207 | + return part.thoughtSignature().map(signature -> signature.length > 0).orElse(false) |
| 208 | + && !part.thought().orElse(false) |
| 209 | + && part.text().map(String::isEmpty).orElse(true) |
| 210 | + && !hasContentFields(part); |
| 211 | + } |
| 212 | + |
| 213 | + /** Whether a part carries only empty text, which Anthropic rejects; mirrors ADK Python. */ |
| 214 | + private static boolean isEmptyTextPart(Part part) { |
| 215 | + return part.text().map(String::isEmpty).orElse(false) |
| 216 | + && part.thoughtSignature().map(signature -> signature.length == 0).orElse(true) |
| 217 | + && !hasContentFields(part); |
| 218 | + } |
| 219 | + |
| 220 | + private @Nullable ContentBlockParam partToAnthropicMessageBlock(Part part) { |
| 221 | + if (isContentFreeSignature(part)) { |
| 222 | + logger.warn("Dropping a thought signature from another model."); |
| 223 | + return null; |
| 224 | + } |
| 225 | + if (isEmptyTextPart(part)) { |
| 226 | + return null; |
| 227 | + } |
| 228 | + if (part.thought().orElse(false) |
| 229 | + && part.functionCall().isEmpty() |
| 230 | + && part.functionResponse().isEmpty()) { |
| 231 | + // Signatures this class stores are UTF-8; a binary one (e.g. Gemini's) is not Claude's. |
| 232 | + Optional<String> signature = |
| 233 | + part.thoughtSignature() |
| 234 | + .filter(bytes -> bytes.length > 0 && Utf8.isWellFormed(bytes)) |
| 235 | + .map(bytes -> new String(bytes, UTF_8)); |
| 236 | + if (signature.isPresent()) { |
| 237 | + // Unlike ADK Python, empty text (display "omitted") stays thinking, not redacted_thinking. |
| 238 | + return part.text().isPresent() |
| 239 | + ? ContentBlockParam.ofThinking( |
| 240 | + ThinkingBlockParam.builder() |
| 241 | + .thinking(part.text().get()) |
| 242 | + .signature(signature.get()) |
| 243 | + .build()) |
| 244 | + : ContentBlockParam.ofRedactedThinking( |
| 245 | + RedactedThinkingBlockParam.builder().data(signature.get()).build()); |
| 246 | + } |
| 247 | + // Other thoughts go out as text (ADK Python sends thinking), or are dropped without text. |
| 248 | + if (part.text().map(String::isEmpty).orElse(true)) { |
| 249 | + return null; |
| 250 | + } |
| 251 | + } |
| 252 | + // Unlike ADK Python, a call or response is always sent, even on a thought or beside text. |
| 253 | + if (part.functionCall().isPresent()) { |
180 | 254 | return ContentBlockParam.ofToolUse( |
181 | 255 | ToolUseBlockParam.builder() |
182 | 256 | .id(part.functionCall().get().id().orElse("")) |
@@ -205,6 +279,8 @@ private ContentBlockParam partToAnthropicMessageBlock(Part part) { |
205 | 279 | .content(content) |
206 | 280 | .isError(false) |
207 | 281 | .build()); |
| 282 | + } else if (part.text().isPresent()) { |
| 283 | + return ContentBlockParam.ofText(TextBlockParam.builder().text(part.text().get()).build()); |
208 | 284 | } |
209 | 285 | throw new UnsupportedOperationException("Not supported yet."); |
210 | 286 | } |
@@ -387,6 +463,19 @@ private Part anthropicContentBlockToPart(ContentBlock block) { |
387 | 463 | .convert(new TypeReference<Map<String, Object>>() {})) |
388 | 464 | .build()) |
389 | 465 | .build(); |
| 466 | + } else if (block.isThinking()) { |
| 467 | + ThinkingBlock thinking = block.asThinking(); |
| 468 | + Part.Builder part = Part.builder().text(thinking.thinking()).thought(true); |
| 469 | + if (!thinking.signature().isEmpty()) { |
| 470 | + part.thoughtSignature(thinking.signature().getBytes(UTF_8)); |
| 471 | + } |
| 472 | + return part.build(); |
| 473 | + } else if (block.isRedactedThinking()) { |
| 474 | + // Keep the encrypted data in thoughtSignature so the block can be sent back unchanged. |
| 475 | + return Part.builder() |
| 476 | + .thought(true) |
| 477 | + .thoughtSignature(block.asRedactedThinking().data().getBytes(UTF_8)) |
| 478 | + .build(); |
390 | 479 | } |
391 | 480 | throw new UnsupportedOperationException("Not supported yet."); |
392 | 481 | } |
|
0 commit comments