[gemini] Fix HTTP 400 when using tool calling with thinking models (#21097)

* [gemini] Fix HTTP 400 when using tool calling with thinking models

Signed-off-by: Florian Hotze <dev@florianhotze.com>
This commit is contained in:
Florian Hotze
2026-07-02 21:04:46 +02:00
committed by GitHub
parent 880b574376
commit b27a5e9248
5 changed files with 82 additions and 18 deletions
@@ -51,7 +51,6 @@ import org.openhab.binding.gemini.internal.api.dto.response.GeminiModelsResponse
import org.openhab.binding.gemini.internal.api.dto.response.GeminiResponse; import org.openhab.binding.gemini.internal.api.dto.response.GeminiResponse;
import org.openhab.core.voice.text.conversation.Conversation; import org.openhab.core.voice.text.conversation.Conversation;
import org.openhab.core.voice.text.interpreter.llm.LLMTool; import org.openhab.core.voice.text.interpreter.llm.LLMTool;
import org.openhab.core.voice.text.interpreter.llm.LLMToolCall;
import org.openhab.core.voice.text.interpreter.llm.LLMToolParam; import org.openhab.core.voice.text.interpreter.llm.LLMToolParam;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -105,7 +104,7 @@ public class GeminiApiClient {
GeminiContent systemInstruction = createSystemInstruction(systemMessage); GeminiContent systemInstruction = createSystemInstruction(systemMessage);
// Contents // Contents
GeminiPart userPart = new GeminiPart(prompt, null, null, null); GeminiPart userPart = new GeminiPart(prompt, null, null, null, null);
GeminiContent userContent = new GeminiContent(ROLE_USER, List.of(userPart)); GeminiContent userContent = new GeminiContent(ROLE_USER, List.of(userPart));
// Config // Config
@@ -141,21 +140,21 @@ public class GeminiApiClient {
for (Conversation.Message msg : history) { for (Conversation.Message msg : history) {
switch (msg.role()) { switch (msg.role()) {
case USER: { case USER: {
GeminiPart part = new GeminiPart(msg.content(), null, null, null); GeminiPart part = new GeminiPart(msg.content(), null, null, null, null);
contents.add(new GeminiContent(ROLE_USER, List.of(part))); contents.add(new GeminiContent(ROLE_USER, new ArrayList<>(List.of(part))));
break; break;
} }
case OPENHAB: { case OPENHAB: {
GeminiPart part = new GeminiPart(msg.content(), null, null, null); GeminiPart part = new GeminiPart(msg.content(), null, null, null, null);
contents.add(new GeminiContent(ROLE_MODEL, List.of(part))); contents.add(new GeminiContent(ROLE_MODEL, new ArrayList<>(List.of(part))));
break; break;
} }
case TOOL_CALL: { case TOOL_CALL: {
LLMToolCall toolCall = LLMToolCall.fromJson(msg.content()); GeminiLLMToolCall toolCall = GeminiLLMToolCall.fromJson(msg.content());
String name = toolCall.tool().replaceAll("[^a-zA-Z0-9_-]", "_"); String name = toolCall.tool.replaceAll("[^a-zA-Z0-9_-]", "_");
pendingToolCallNames.add(name); pendingToolCallNames.add(name);
GeminiFunctionCall fc = new GeminiFunctionCall(name, toolCall.params()); GeminiFunctionCall fc = new GeminiFunctionCall(name, toolCall.params, toolCall.id);
GeminiPart part = new GeminiPart(null, fc, null, null); GeminiPart part = new GeminiPart(null, fc, null, null, toolCall.thoughtSignature);
contents.add(new GeminiContent(ROLE_MODEL, List.of(part))); contents.add(new GeminiContent(ROLE_MODEL, List.of(part)));
break; break;
} }
@@ -166,8 +165,21 @@ public class GeminiApiClient {
break; // TOOL_RETURN without preceding TOOL_CALL - ignore break; // TOOL_RETURN without preceding TOOL_CALL - ignore
} }
GeminiFunctionResponse fr = new GeminiFunctionResponse(name, Map.of("result", msg.content())); GeminiFunctionResponse fr = new GeminiFunctionResponse(name, Map.of("result", msg.content()));
GeminiPart part = new GeminiPart(null, null, fr, null); GeminiPart part = new GeminiPart(null, null, fr, null, null);
contents.add(new GeminiContent(ROLE_USER, List.of(part)));
// Consolidate consecutive TOOL_RETURNs into the same ROLE_USER content
if (!contents.isEmpty() && ROLE_USER.equals(contents.getLast().role())) {
GeminiContent lastContent = contents.getLast();
List<GeminiPart> lastParts = lastContent.parts();
if (lastParts != null && !lastParts.isEmpty()
&& lastParts.getFirst().functionResponse() != null) {
lastParts.add(part);
} else {
contents.add(new GeminiContent(ROLE_USER, new ArrayList<>(List.of(part))));
}
} else {
contents.add(new GeminiContent(ROLE_USER, new ArrayList<>(List.of(part))));
}
break; break;
} }
case THINKING: case THINKING:
@@ -356,7 +368,7 @@ public class GeminiApiClient {
private @Nullable GeminiContent createSystemInstruction(@Nullable String systemMessage) { private @Nullable GeminiContent createSystemInstruction(@Nullable String systemMessage) {
if (systemMessage != null && !systemMessage.isBlank()) { if (systemMessage != null && !systemMessage.isBlank()) {
GeminiPart sysPart = new GeminiPart(systemMessage, null, null, null); GeminiPart sysPart = new GeminiPart(systemMessage, null, null, null, null);
return new GeminiContent(null, List.of(sysPart)); return new GeminiContent(null, List.of(sysPart));
} }
return null; return null;
@@ -0,0 +1,51 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.gemini.internal.api;
import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.voice.text.interpreter.llm.LLMToolCall;
import com.google.gson.JsonSyntaxException;
/**
* A DTO to store information about a Gemini tool call.
* Extends {@link LLMToolCall} with additional fields required by the Gemini API.
*
* @author Florian Hotze - Initial contribution
*/
@NonNullByDefault
public class GeminiLLMToolCall extends LLMToolCall {
public final @Nullable String id;
public final @Nullable String thoughtSignature;
public GeminiLLMToolCall(String tool, Map<String, Object> params, @Nullable String id,
@Nullable String thoughtSignature) {
super(tool, params);
this.id = id;
this.thoughtSignature = thoughtSignature;
}
public static GeminiLLMToolCall fromJson(String json) throws JsonSyntaxException {
GeminiLLMToolCall call = GSON.fromJson(json, GeminiLLMToolCall.class);
if (call == null) {
throw new JsonSyntaxException("Deserialized GeminiLLMToolCall is null.");
}
if (call.tool == null || call.params == null) {
throw new JsonSyntaxException("Deserialized GeminiLLMToolCall has null tool or params.");
}
return call;
}
}
@@ -27,5 +27,5 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
*/ */
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
@NonNullByDefault @NonNullByDefault
public record GeminiFunctionCall(@Nullable String name, @Nullable Map<String, Object> args) { public record GeminiFunctionCall(@Nullable String name, @Nullable Map<String, Object> args, @Nullable String id) {
} }
@@ -27,5 +27,6 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
@NonNullByDefault @NonNullByDefault
public record GeminiPart(@Nullable String text, @Nullable GeminiFunctionCall functionCall, public record GeminiPart(@Nullable String text, @Nullable GeminiFunctionCall functionCall,
@Nullable GeminiFunctionResponse functionResponse, @Nullable Boolean thought) { @Nullable GeminiFunctionResponse functionResponse, @Nullable Boolean thought,
@Nullable String thoughtSignature) {
} }
@@ -28,6 +28,7 @@ import org.openhab.binding.gemini.internal.GeminiConfiguration;
import org.openhab.binding.gemini.internal.GeminiHandler; import org.openhab.binding.gemini.internal.GeminiHandler;
import org.openhab.binding.gemini.internal.api.GeminiApiClient; import org.openhab.binding.gemini.internal.api.GeminiApiClient;
import org.openhab.binding.gemini.internal.api.GeminiApiException; import org.openhab.binding.gemini.internal.api.GeminiApiException;
import org.openhab.binding.gemini.internal.api.GeminiLLMToolCall;
import org.openhab.binding.gemini.internal.api.dto.GeminiContent; import org.openhab.binding.gemini.internal.api.dto.GeminiContent;
import org.openhab.binding.gemini.internal.api.dto.GeminiFunctionCall; import org.openhab.binding.gemini.internal.api.dto.GeminiFunctionCall;
import org.openhab.binding.gemini.internal.api.dto.GeminiPart; import org.openhab.binding.gemini.internal.api.dto.GeminiPart;
@@ -43,7 +44,6 @@ import org.openhab.core.voice.text.conversation.Conversation;
import org.openhab.core.voice.text.conversation.ConversationException; import org.openhab.core.voice.text.conversation.ConversationException;
import org.openhab.core.voice.text.conversation.ConversationRole; import org.openhab.core.voice.text.conversation.ConversationRole;
import org.openhab.core.voice.text.interpreter.llm.LLMTool; import org.openhab.core.voice.text.interpreter.llm.LLMTool;
import org.openhab.core.voice.text.interpreter.llm.LLMToolCall;
import org.openhab.core.voice.text.interpreter.llm.LLMToolException; import org.openhab.core.voice.text.interpreter.llm.LLMToolException;
import org.osgi.framework.Bundle; import org.osgi.framework.Bundle;
import org.osgi.framework.BundleContext; import org.osgi.framework.BundleContext;
@@ -252,8 +252,8 @@ public class GeminiHLIService implements ThingHandlerService, HumanLanguageInter
String toolName = fc.name(); String toolName = fc.name();
Map<String, Object> args = fc.args(); Map<String, Object> args = fc.args();
LLMToolCall llmToolCall = new LLMToolCall(toolName != null ? toolName : "", GeminiLLMToolCall llmToolCall = new GeminiLLMToolCall(toolName != null ? toolName : "",
args != null ? args : new HashMap<>()); args != null ? args : new HashMap<>(), fc.id(), part.thoughtSignature());
try { try {
conversation.addMessage(ConversationRole.TOOL_CALL, llmToolCall.toJson()); conversation.addMessage(ConversationRole.TOOL_CALL, llmToolCall.toJson());
} catch (ConversationException e) { } catch (ConversationException e) {