Skip to content

Commit d675e72

Browse files
Merge pull request #1634 from innoprej:fix/claude-thinking-blocks
PiperOrigin-RevId: 1002208296
2 parents 97f22a2 + d1d9dc2 commit d675e72

2 files changed

Lines changed: 545 additions & 12 deletions

File tree

‎core/src/main/java/com/google/adk/models/Claude.java‎

Lines changed: 101 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,19 @@
1616

1717
package com.google.adk.models;
1818

19+
import static java.nio.charset.StandardCharsets.UTF_8;
20+
1921
import com.anthropic.client.AnthropicClient;
2022
import com.anthropic.models.messages.ContentBlock;
2123
import com.anthropic.models.messages.ContentBlockParam;
2224
import com.anthropic.models.messages.Message;
2325
import com.anthropic.models.messages.MessageCreateParams;
2426
import com.anthropic.models.messages.MessageParam;
2527
import com.anthropic.models.messages.MessageParam.Role;
28+
import com.anthropic.models.messages.RedactedThinkingBlockParam;
2629
import com.anthropic.models.messages.TextBlockParam;
30+
import com.anthropic.models.messages.ThinkingBlock;
31+
import com.anthropic.models.messages.ThinkingBlockParam;
2732
import com.anthropic.models.messages.Tool;
2833
import com.anthropic.models.messages.ToolChoice;
2934
import com.anthropic.models.messages.ToolChoiceAuto;
@@ -32,6 +37,7 @@
3237
import com.anthropic.models.messages.ToolUseBlockParam;
3338
import com.fasterxml.jackson.core.type.TypeReference;
3439
import com.google.adk.JsonBaseModel;
40+
import com.google.common.base.Utf8;
3541
import com.google.common.collect.ImmutableList;
3642
import com.google.common.collect.ImmutableMap;
3743
import com.google.genai.types.*;
@@ -44,6 +50,7 @@
4450
import java.util.Objects;
4551
import java.util.Optional;
4652
import java.util.stream.Collectors;
53+
import org.jspecify.annotations.Nullable;
4754
import org.slf4j.Logger;
4855
import org.slf4j.LoggerFactory;
4956

@@ -104,6 +111,8 @@ public Flowable<LlmResponse> generateContent(LlmRequest llmRequest, boolean stre
104111
List<MessageParam> messages =
105112
llmRequest.contents().stream()
106113
.map(this::contentToAnthropicMessageParam)
114+
// Anthropic rejects empty messages, e.g. a turn whose parts were all dropped.
115+
.filter(message -> !message.content().asBlockParams().isEmpty())
107116
.collect(Collectors.toList());
108117

109118
List<ToolUnion> tools = ImmutableList.of();
@@ -163,20 +172,85 @@ private Role toClaudeRole(String role) {
163172
}
164173

165174
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();
174190
}
175191

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()) {
180254
return ContentBlockParam.ofToolUse(
181255
ToolUseBlockParam.builder()
182256
.id(part.functionCall().get().id().orElse(""))
@@ -205,6 +279,8 @@ private ContentBlockParam partToAnthropicMessageBlock(Part part) {
205279
.content(content)
206280
.isError(false)
207281
.build());
282+
} else if (part.text().isPresent()) {
283+
return ContentBlockParam.ofText(TextBlockParam.builder().text(part.text().get()).build());
208284
}
209285
throw new UnsupportedOperationException("Not supported yet.");
210286
}
@@ -387,6 +463,19 @@ private Part anthropicContentBlockToPart(ContentBlock block) {
387463
.convert(new TypeReference<Map<String, Object>>() {}))
388464
.build())
389465
.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();
390479
}
391480
throw new UnsupportedOperationException("Not supported yet.");
392481
}

0 commit comments

Comments
 (0)