mirror of
https://github.com/danieldemus/openhab-addons
synced 2026-07-29 12:34:21 +02:00
[gemini] Initial contribution (#20922)
* [gemini] Add binding skeleton Signed-off-by: Florian Hotze <dev@florianhotze.com>
This commit is contained in:
@@ -144,6 +144,7 @@
|
||||
/bundles/org.openhab.binding.ftpupload/ @paulianttila
|
||||
/bundles/org.openhab.binding.gardena/ @gerrieg @andrewfg
|
||||
/bundles/org.openhab.binding.gce/ @clinique
|
||||
/bundles/org.openhab.binding.gemini/ @florian-h05
|
||||
/bundles/org.openhab.binding.generacmobilelink/ @digitaldan
|
||||
/bundles/org.openhab.binding.globalcache/ @mhilbush
|
||||
/bundles/org.openhab.binding.goecharger/ @SamuelBrucksch
|
||||
|
||||
@@ -706,6 +706,11 @@
|
||||
<artifactId>org.openhab.binding.gce</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openhab.addons.bundles</groupId>
|
||||
<artifactId>org.openhab.binding.gemini</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openhab.addons.bundles</groupId>
|
||||
<artifactId>org.openhab.binding.generacmobilelink</artifactId>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
This content is produced and maintained by the openHAB project.
|
||||
|
||||
* Project home: https://www.openhab.org
|
||||
|
||||
== Declared Project Licenses
|
||||
|
||||
This program and the accompanying materials are made available under the terms
|
||||
of the Eclipse Public License 2.0 which is available at
|
||||
https://www.eclipse.org/legal/epl-2.0/.
|
||||
|
||||
== Source Code
|
||||
|
||||
https://github.com/openhab/openhab-addons
|
||||
@@ -0,0 +1,182 @@
|
||||
# Google Gemini Binding
|
||||
|
||||
The openHAB Google Gemini Binding allows openHAB to communicate with the Gemini family of large language models (LLM) from Google.
|
||||
|
||||
Google Gemini is a powerful natural language processing (NLP) tool that can be used to understand and respond to a wide range of text-based commands and questions.
|
||||
This binding provides the following features:
|
||||
|
||||
- Control openHAB with natural language commands: Turn on the lights, control the TV or check the status of the garage door.
|
||||
- Multi-Language Support: Issue commands and ask questions in almost any language.
|
||||
- Chat with a LLM: Ask questions and receive informational responses.
|
||||
|
||||
## Supported Things
|
||||
|
||||
The binding supports a single thing type `account`, which corresponds to the Google Gemini account that is to be used for the integration.
|
||||
|
||||
## Discovery
|
||||
|
||||
Discovery is not supported.
|
||||
Things must be added manually.
|
||||
|
||||
## Thing Configuration
|
||||
|
||||
The `account` thing requires the API key that allows accessing the account.
|
||||
API keys can be created and managed under Google AI Studio: <https://aistudio.google.com/app/apikey>.
|
||||
|
||||
| Name | Type | Description | Default | Required | Advanced |
|
||||
|-----------------|---------|------------------------------------------------------------------------------------------------------------------------------------------|------------------|----------|----------|
|
||||
| apiKey | text | The API key to authenticate against the Gemini API. | N/A | yes | no |
|
||||
| requestTimeout | integer | Timeout in seconds for chat API requests. | 30 | no | yes |
|
||||
| model | text | The model to be used ([Models](https://ai.google.dev/gemini-api/docs/models), [Pricing](https://ai.google.dev/gemini-api/docs/pricing)). | gemini-2.5-flash | no | no |
|
||||
| temperature | decimal | A value between 0.0 and 1.0, where higher values make the output more random and lower values make it more focused and deterministic. | 1.0 | no | yes |
|
||||
| topP | decimal | A value between 0.0 and 1.0 for nucleus sampling, where the model considers the results of the tokens with topP probability mass. | 1.0 | no | yes |
|
||||
| maxOutputTokens | integer | The maximum number of tokens to include in a candidate. | 2048 | no | yes |
|
||||
|
||||
It is generally recommended to either alter temperature or topP, but not both.
|
||||
For Gemini 3.x models, Google recommends keeping both values at their default.
|
||||
|
||||
## Channels
|
||||
|
||||
The `account` thing comes with a single `chat` channel of type `chat` by default.
|
||||
Additional channels can be added to the thing, allowing specifying different models and parameters for each channel.
|
||||
|
||||
Channels of type `chat` take the following configuration parameters:
|
||||
|
||||
| Name | Type | Description | Required | Advanced |
|
||||
|-----------------|---------|------------------------------------------------------------------------------------------------|----------|----------|
|
||||
| model | text | The model to be used for the responses, overriding the thing-level model. | no | no |
|
||||
| systemMessage | text | The system message helps set the behavior of the assistant. | no | no |
|
||||
| temperature | decimal | A value between 0.0 and 2.0, which overrides the thing-level temperature. | no | yes |
|
||||
| topP | decimal | A value between 0.0 and 1.0, which overrides the thing-level topP. | no | yes |
|
||||
| maxOutputTokens | integer | The maximum number of tokens to include in a candidate, overriding the thing-level max tokens. | no | yes |
|
||||
|
||||
Channel configuration defaults to the [Thing configuration](#thing-configuration), except for `systemMessage`, which defaults to _You are a helpful assistant_.
|
||||
|
||||
## Thing Actions
|
||||
|
||||
The binding provides actions to interact with the Google Gemini API directly from rules.
|
||||
The first parameter when retrieving actions must always be `gemini` and the second must be the full Thing UID of the Gemini account.
|
||||
|
||||
You can retrieve the actions as follows:
|
||||
|
||||
:::: tabs
|
||||
|
||||
::: tab DSL
|
||||
|
||||
```java
|
||||
val geminiActions = getActions("gemini", "gemini:account:myaccount")
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
::: tab JS
|
||||
|
||||
```javascript
|
||||
var geminiActions = actions.thingActions("gemini", "gemini:account:myaccount");
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
::::
|
||||
|
||||
### Available Actions
|
||||
|
||||
The `account` Thing provides the following actions:
|
||||
|
||||
| Action Signature | Return Type | Description |
|
||||
|----------------------------------------------------------------------------------------------------------------------------------------------------|-------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `sendMessage(String prompt)` | `String` | Sends a prompt to Gemini using the Thing's configured model and parameters, and returns the response text, or `null` if the request failed. |
|
||||
| `sendMessage(String prompt, String model)` | `String` | Sends a prompt to Gemini using the specified model, falling back to default parameters, and returns the response text, or `null` if the request failed. |
|
||||
| `sendMessage(String prompt, String model, String systemMessage, Double temperature, Double topP, Integer maxOutputTokens, Integer requestTimeout)` | `String` | Sends a prompt with detailed generation settings (where null values fall back to [Thing configuration](#thing-configuration)), and returns the response text, or `null` if the request failed. |
|
||||
|
||||
### Examples
|
||||
|
||||
:::: tabs
|
||||
|
||||
::: tab DSL
|
||||
|
||||
```java
|
||||
val geminiActions = getActions("gemini", "gemini:account:myaccount")
|
||||
|
||||
// 1. Simple sendMessage
|
||||
val response1 = geminiActions.sendMessage("What is the capital of France?")
|
||||
logInfo("Gemini", "Response 1: " + response1)
|
||||
|
||||
// 2. sendMessage with model override
|
||||
val response2 = geminiActions.sendMessage("Write a poem about openHAB.", "gemini-2.5-flash")
|
||||
logInfo("Gemini", "Response 2: " + response2)
|
||||
|
||||
// 3. sendMessage with custom parameters (use null for default values)
|
||||
val response3 = geminiActions.sendMessage(
|
||||
"How does electricity work?",
|
||||
"gemini-2.5-flash",
|
||||
"Explain it to a 5-year-old.", // system message
|
||||
0.7, // temperature
|
||||
null, // topP (default)
|
||||
500, // maxOutputTokens
|
||||
60 // requestTimeout
|
||||
)
|
||||
logInfo("Gemini", "Response 3: " + response3)
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
::: tab JS
|
||||
|
||||
```javascript
|
||||
const geminiActions = actions.thingActions("gemini", "gemini:account:myaccount");
|
||||
|
||||
// 1. Simple sendMessage
|
||||
const response1 = geminiActions.sendMessage("What is the capital of France?");
|
||||
console.info("Gemini Response 1: " + response1);
|
||||
|
||||
// 2. sendMessage with model override
|
||||
const response2 = geminiActions.sendMessage("Write a poem about openHAB.", "gemini-2.5-flash");
|
||||
console.info("Gemini Response 2: " + response2);
|
||||
|
||||
// 3. sendMessage with custom parameters (use null for default values)
|
||||
const response3 = geminiActions.sendMessage(
|
||||
"How does electricity work?",
|
||||
"gemini-2.5-flash",
|
||||
"Explain it to a 5-year-old.", // system message
|
||||
0.7, // temperature
|
||||
null, // topP (default)
|
||||
500, // maxOutputTokens
|
||||
60 // requestTimeout
|
||||
);
|
||||
console.info("Gemini Response 3: " + response3);
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
::::
|
||||
|
||||
## Human Language Interpreter
|
||||
|
||||
The `account` Thing automatically registers a human language interpreter implementation.
|
||||
|
||||
To configure the Gemini HLI as default, go to _Settings_ → _Voice_ and select _Gemini Human Language Interpreter_ as default.
|
||||
|
||||
In that place, you can also configure the system prompt used to instruct the LLM on how to process the user's input.
|
||||
The used model, temperature, topP and maximum output tokens parameters can be configured in the thing configuration.
|
||||
|
||||
For more information on human language interpreters, refer to the [Voice documentation]({{base}}/docs/configuration/multimedia.html#human-language-interpreter).
|
||||
|
||||
## Full Example
|
||||
|
||||
### Thing Configuration
|
||||
|
||||
```java
|
||||
Thing gemini:account:myaccount [apiKey="xxx-yyy-zzz", model="gemini-2.5-flash", temperature=1.0, topP=1.0, maxOutputTokens=2048, requestTimeout=30] {
|
||||
Channels:
|
||||
Type chat : chat "Chat" [model="gemini-2.5-flash", temperature=1.0, topP=1.0, maxOutputTokens=2048, systemMessage="You are a helpful assistant."]
|
||||
}
|
||||
```
|
||||
|
||||
Additional channels of type `chat` can be added to the thing.
|
||||
|
||||
### Item Configuration
|
||||
|
||||
```java
|
||||
String GeminiChat "Gemini Chat" { channel="gemini:account:myaccount:chat" }
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.openhab.addons.bundles</groupId>
|
||||
<artifactId>org.openhab.addons.reactor.bundles</artifactId>
|
||||
<version>5.2.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>org.openhab.binding.gemini</artifactId>
|
||||
|
||||
<name>openHAB Add-ons :: Bundles :: Gemini Binding</name>
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<features name="org.openhab.binding.gemini-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.6.0">
|
||||
<repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
|
||||
|
||||
<feature name="openhab-binding-gemini" description="Gemini Binding" version="${project.version}">
|
||||
<feature>openhab-runtime-base</feature>
|
||||
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.gemini/${project.version}</bundle>
|
||||
</feature>
|
||||
</features>
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.openhab.core.thing.ThingTypeUID;
|
||||
import org.openhab.core.thing.type.ChannelTypeUID;
|
||||
|
||||
/**
|
||||
* The {@link GeminiBindingConstants} class defines common constants, which are
|
||||
* used across the whole binding.
|
||||
*
|
||||
* @author Florian Hotze - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class GeminiBindingConstants {
|
||||
public static final String BINDING_ID = "gemini";
|
||||
|
||||
// List of all Thing Type UIDs
|
||||
public static final ThingTypeUID THING_TYPE_ACCOUNT = new ThingTypeUID(BINDING_ID, "account");
|
||||
|
||||
// List of all Channel ids
|
||||
public static final String CHANNEL_CHAT = "chat";
|
||||
public static final ChannelTypeUID CHANNEL_TYPE_UID_CHAT = new ChannelTypeUID(BINDING_ID, CHANNEL_CHAT);
|
||||
|
||||
// Default values for configuration parameters
|
||||
public static final String DEFAULT_MODEL = "gemini-2.5-flash";
|
||||
public static final double DEFAULT_TEMPERATURE = 1.0;
|
||||
public static final double DEFAULT_TOP_P = 1.0;
|
||||
public static final int DEFAULT_MAX_OUTPUT_TOKENS = 2048;
|
||||
public static final String DEFAULT_SYSTEM_MESSAGE = "You are a helpful assistant.";
|
||||
public static final int DEFAULT_REQUEST_TIMEOUT = 30;
|
||||
|
||||
// Default values as string needed for annotations
|
||||
public static final String DEFAULT_TEMPERATURE_STR = "1.0";
|
||||
public static final String DEFAULT_TOP_P_STR = "1.0";
|
||||
public static final String MAX_OUTPUT_TOKENS_STR = "2048";
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
|
||||
/**
|
||||
* The {@link GeminiChannelConfiguration} class contains the configuration for a Gemini chat channel.
|
||||
* Values can be null/empty, in which case they fall back to the defaults from the thing-type.xml.
|
||||
*
|
||||
* @author Florian Hotze - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class GeminiChannelConfiguration {
|
||||
public @Nullable String model;
|
||||
public @Nullable Double temperature;
|
||||
public @Nullable Double topP;
|
||||
public @Nullable Integer maxOutputTokens;
|
||||
public @Nullable String systemMessage;
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
import static org.openhab.binding.gemini.internal.GeminiBindingConstants.DEFAULT_MAX_OUTPUT_TOKENS;
|
||||
import static org.openhab.binding.gemini.internal.GeminiBindingConstants.DEFAULT_MODEL;
|
||||
import static org.openhab.binding.gemini.internal.GeminiBindingConstants.DEFAULT_REQUEST_TIMEOUT;
|
||||
import static org.openhab.binding.gemini.internal.GeminiBindingConstants.DEFAULT_TEMPERATURE;
|
||||
import static org.openhab.binding.gemini.internal.GeminiBindingConstants.DEFAULT_TOP_P;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* The {@link GeminiConfiguration} class contains the configuration for the Gemini account thing.
|
||||
*
|
||||
* @author Florian Hotze - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class GeminiConfiguration {
|
||||
// Authentication
|
||||
public String apiKey = "";
|
||||
// Connection
|
||||
public int requestTimeout = DEFAULT_REQUEST_TIMEOUT;
|
||||
// HLI
|
||||
public String model = DEFAULT_MODEL;
|
||||
public double temperature = DEFAULT_TEMPERATURE;
|
||||
public double topP = DEFAULT_TOP_P;
|
||||
public int maxOutputTokens = DEFAULT_MAX_OUTPUT_TOKENS;
|
||||
}
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
import static org.openhab.binding.gemini.internal.GeminiBindingConstants.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.eclipse.jetty.client.HttpClient;
|
||||
import org.openhab.binding.gemini.internal.action.GeminiActions;
|
||||
import org.openhab.binding.gemini.internal.api.GeminiApiClient;
|
||||
import org.openhab.binding.gemini.internal.api.GeminiApiException;
|
||||
import org.openhab.binding.gemini.internal.api.dto.response.GeminiModel;
|
||||
import org.openhab.binding.gemini.internal.api.dto.response.GeminiResponse;
|
||||
import org.openhab.binding.gemini.internal.hli.GeminiHLIService;
|
||||
import org.openhab.core.io.net.http.HttpClientFactory;
|
||||
import org.openhab.core.library.types.StringType;
|
||||
import org.openhab.core.thing.Channel;
|
||||
import org.openhab.core.thing.ChannelUID;
|
||||
import org.openhab.core.thing.Thing;
|
||||
import org.openhab.core.thing.ThingStatus;
|
||||
import org.openhab.core.thing.ThingStatusDetail;
|
||||
import org.openhab.core.thing.binding.BaseThingHandler;
|
||||
import org.openhab.core.thing.binding.ThingHandlerService;
|
||||
import org.openhab.core.types.Command;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* The {@link GeminiHandler} is responsible for handling commands, which are
|
||||
* sent to one of the channels.
|
||||
*
|
||||
* @author Florian Hotze - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class GeminiHandler extends BaseThingHandler {
|
||||
private final Logger logger = LoggerFactory.getLogger(GeminiHandler.class);
|
||||
|
||||
private final HttpClient httpClient;
|
||||
private @Nullable GeminiConfiguration config;
|
||||
private @Nullable GeminiApiClient apiClient;
|
||||
private List<GeminiModel> models = List.of();
|
||||
private @Nullable ScheduledFuture<?> initFuture;
|
||||
|
||||
public GeminiHandler(Thing thing, HttpClientFactory httpClientFactory) {
|
||||
super(thing);
|
||||
this.httpClient = httpClientFactory.getCommonHttpClient();
|
||||
}
|
||||
|
||||
public @Nullable GeminiConfiguration getGeminiConfiguration() {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
public @Nullable GeminiApiClient getApiClient() {
|
||||
return apiClient;
|
||||
}
|
||||
|
||||
public List<GeminiModel> getModels() {
|
||||
return models;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleCommand(ChannelUID channelUID, Command command) {
|
||||
Channel channel = getThing().getChannel(channelUID);
|
||||
if (channel != null && CHANNEL_TYPE_UID_CHAT.equals(channel.getChannelTypeUID())
|
||||
&& command instanceof StringType stringCommand) {
|
||||
String lastPrompt = stringCommand.toFullString();
|
||||
|
||||
GeminiConfiguration config = this.config;
|
||||
GeminiApiClient client = apiClient;
|
||||
if (client != null) {
|
||||
int timeout = config != null ? config.requestTimeout : DEFAULT_REQUEST_TIMEOUT;
|
||||
String model = (config != null && !config.model.isBlank()) ? config.model : DEFAULT_MODEL;
|
||||
double temp = config != null ? config.temperature : DEFAULT_TEMPERATURE;
|
||||
double topP = config != null ? config.topP : DEFAULT_TOP_P;
|
||||
int maxTokens = config != null ? config.maxOutputTokens : DEFAULT_MAX_OUTPUT_TOKENS;
|
||||
String systemMessage = DEFAULT_SYSTEM_MESSAGE;
|
||||
|
||||
GeminiChannelConfiguration channelConfig = channel.getConfiguration()
|
||||
.as(GeminiChannelConfiguration.class);
|
||||
|
||||
String channelModel = channelConfig.model;
|
||||
if (channelModel != null && !channelModel.isBlank()) {
|
||||
model = channelModel;
|
||||
}
|
||||
|
||||
Double channelTemp = channelConfig.temperature;
|
||||
if (channelTemp != null) {
|
||||
temp = channelTemp;
|
||||
}
|
||||
|
||||
Double channelTopP = channelConfig.topP;
|
||||
if (channelTopP != null) {
|
||||
topP = channelTopP;
|
||||
}
|
||||
|
||||
Integer channelMaxTokens = channelConfig.maxOutputTokens;
|
||||
if (channelMaxTokens != null) {
|
||||
maxTokens = channelMaxTokens;
|
||||
}
|
||||
|
||||
String channelSystemMessage = channelConfig.systemMessage;
|
||||
if (channelSystemMessage != null && !channelSystemMessage.isBlank()) {
|
||||
systemMessage = channelSystemMessage;
|
||||
}
|
||||
|
||||
try {
|
||||
GeminiResponse response = client.sendPrompt(model, lastPrompt, systemMessage, temp, topP, maxTokens,
|
||||
timeout);
|
||||
processChatResponse(channelUID, response);
|
||||
updateStatus(ThingStatus.ONLINE);
|
||||
} catch (GeminiApiException e) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
|
||||
"Could not connect to Gemini API: " + e.getMessage());
|
||||
logger.debug("Request to Gemini failed: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void processChatResponse(ChannelUID channelUID, GeminiResponse response) {
|
||||
String msg = response.getFirstText();
|
||||
if (msg != null) {
|
||||
updateState(channelUID, new StringType(msg));
|
||||
} else {
|
||||
logger.warn("Didn't receive any chat response candidates from Gemini - this is unexpected.");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void initialize() {
|
||||
ScheduledFuture<?> future = initFuture;
|
||||
if (future != null) {
|
||||
future.cancel(true);
|
||||
this.initFuture = null;
|
||||
}
|
||||
|
||||
final GeminiConfiguration c = getConfigAs(GeminiConfiguration.class);
|
||||
this.config = c;
|
||||
|
||||
String apiKey = c.apiKey;
|
||||
|
||||
if (apiKey.isBlank()) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
|
||||
"@text/offline.configuration-error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (invalidTimeout(c.requestTimeout)) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
|
||||
"@text/requestTimeout.configuration-error");
|
||||
return;
|
||||
}
|
||||
|
||||
this.apiClient = new GeminiApiClient(httpClient, apiKey);
|
||||
|
||||
updateStatus(ThingStatus.UNKNOWN);
|
||||
|
||||
this.initFuture = scheduler.schedule(() -> {
|
||||
try {
|
||||
GeminiApiClient client = this.apiClient;
|
||||
GeminiConfiguration currentConfig = this.config;
|
||||
if (client == null || currentConfig == null) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
|
||||
"@text/offline.configuration-error");
|
||||
return;
|
||||
}
|
||||
List<GeminiModel> apiModels = client.fetchModels(currentConfig.requestTimeout);
|
||||
if (!apiModels.isEmpty()) {
|
||||
List<GeminiModel> modelList = new ArrayList<>();
|
||||
Set<String> seenNames = new HashSet<>();
|
||||
for (GeminiModel model : apiModels) {
|
||||
String name = model.name();
|
||||
if (name != null) {
|
||||
List<String> methods = model.supportedGenerationMethods();
|
||||
if (methods != null && methods.contains("generateContent")) {
|
||||
String cleanName = name.replace("models/", "");
|
||||
String lowerName = cleanName.toLowerCase(Locale.ROOT);
|
||||
if (lowerName.startsWith("gemini") || lowerName.startsWith("gemma")) {
|
||||
if (seenNames.add(cleanName)) {
|
||||
modelList.add(model);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this.models = List.copyOf(modelList);
|
||||
updateStatus(ThingStatus.ONLINE);
|
||||
} else {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
|
||||
"@text/offline.communication-error");
|
||||
}
|
||||
} catch (GeminiApiException e) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
|
||||
}
|
||||
}, 0, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
ScheduledFuture<?> future = initFuture;
|
||||
if (future != null) {
|
||||
future.cancel(true);
|
||||
this.initFuture = null;
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<Class<? extends ThingHandlerService>> getServices() {
|
||||
return List.of(GeminiModelOptionProvider.class, GeminiActions.class, GeminiHLIService.class);
|
||||
}
|
||||
|
||||
private boolean invalidTimeout(int timeout) {
|
||||
return timeout <= 0;
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
import static org.openhab.binding.gemini.internal.GeminiBindingConstants.THING_TYPE_ACCOUNT;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.core.io.net.http.HttpClientFactory;
|
||||
import org.openhab.core.thing.Thing;
|
||||
import org.openhab.core.thing.ThingTypeUID;
|
||||
import org.openhab.core.thing.binding.BaseThingHandlerFactory;
|
||||
import org.openhab.core.thing.binding.ThingHandler;
|
||||
import org.openhab.core.thing.binding.ThingHandlerFactory;
|
||||
import org.osgi.service.component.annotations.Activate;
|
||||
import org.osgi.service.component.annotations.Component;
|
||||
import org.osgi.service.component.annotations.Reference;
|
||||
|
||||
/**
|
||||
* The {@link GeminiHandlerFactory} is responsible for creating things and thing
|
||||
* handlers.
|
||||
*
|
||||
* @author Florian Hotze - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
@Component(configurationPid = "binding.gemini", service = ThingHandlerFactory.class)
|
||||
public class GeminiHandlerFactory extends BaseThingHandlerFactory {
|
||||
|
||||
private static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_ACCOUNT);
|
||||
private HttpClientFactory httpClientFactory;
|
||||
|
||||
@Activate
|
||||
public GeminiHandlerFactory(@Reference HttpClientFactory httpClientFactory) {
|
||||
this.httpClientFactory = httpClientFactory;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsThingType(ThingTypeUID thingTypeUID) {
|
||||
return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected @Nullable ThingHandler createHandler(Thing thing) {
|
||||
ThingTypeUID thingTypeUID = thing.getThingTypeUID();
|
||||
|
||||
if (THING_TYPE_ACCOUNT.equals(thingTypeUID)) {
|
||||
return new GeminiHandler(thing, httpClientFactory);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
import static org.openhab.binding.gemini.internal.GeminiBindingConstants.CHANNEL_TYPE_UID_CHAT;
|
||||
import static org.openhab.binding.gemini.internal.GeminiBindingConstants.THING_TYPE_ACCOUNT;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.core.config.core.ConfigOptionProvider;
|
||||
import org.openhab.core.config.core.ParameterOption;
|
||||
import org.openhab.core.thing.binding.ThingHandler;
|
||||
import org.openhab.core.thing.binding.ThingHandlerService;
|
||||
import org.osgi.service.component.annotations.Component;
|
||||
import org.osgi.service.component.annotations.ServiceScope;
|
||||
|
||||
/**
|
||||
* The {@link GeminiModelOptionProvider} provides the available models from Gemini as configuration options.
|
||||
*
|
||||
* @author Florian Hotze - Initial contribution
|
||||
*/
|
||||
@Component(scope = ServiceScope.PROTOTYPE, service = { GeminiModelOptionProvider.class, ConfigOptionProvider.class })
|
||||
@NonNullByDefault
|
||||
public class GeminiModelOptionProvider implements ThingHandlerService, ConfigOptionProvider {
|
||||
private @Nullable ThingHandler thingHandler;
|
||||
|
||||
@Override
|
||||
public void setThingHandler(ThingHandler handler) {
|
||||
this.thingHandler = handler;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable ThingHandler getThingHandler() {
|
||||
return thingHandler;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable Collection<ParameterOption> getParameterOptions(URI uri, String param, @Nullable String context,
|
||||
@Nullable Locale locale) {
|
||||
if (CHANNEL_TYPE_UID_CHAT.getAsString().equals(uri.getSchemeSpecificPart())
|
||||
|| THING_TYPE_ACCOUNT.getAsString().equals(uri.getSchemeSpecificPart())) {
|
||||
if ("model".equals(param)) {
|
||||
List<ParameterOption> options = new ArrayList<>();
|
||||
if (thingHandler instanceof GeminiHandler geminiHandler) {
|
||||
geminiHandler.getModels().forEach(model -> {
|
||||
String name = model.name();
|
||||
if (name != null) {
|
||||
String cleanName = name.replace("models/", "");
|
||||
String displayName = model.displayName();
|
||||
String label = displayName != null ? displayName : cleanName;
|
||||
options.add(new ParameterOption(cleanName, label));
|
||||
}
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* 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.action;
|
||||
|
||||
import static org.openhab.binding.gemini.internal.GeminiBindingConstants.*;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.binding.gemini.internal.GeminiConfiguration;
|
||||
import org.openhab.binding.gemini.internal.GeminiHandler;
|
||||
import org.openhab.binding.gemini.internal.api.GeminiApiClient;
|
||||
import org.openhab.binding.gemini.internal.api.GeminiApiException;
|
||||
import org.openhab.binding.gemini.internal.api.dto.response.GeminiResponse;
|
||||
import org.openhab.core.automation.annotation.ActionInput;
|
||||
import org.openhab.core.automation.annotation.ActionOutput;
|
||||
import org.openhab.core.automation.annotation.RuleAction;
|
||||
import org.openhab.core.thing.binding.ThingActions;
|
||||
import org.openhab.core.thing.binding.ThingActionsScope;
|
||||
import org.openhab.core.thing.binding.ThingHandler;
|
||||
import org.osgi.service.component.annotations.Component;
|
||||
import org.osgi.service.component.annotations.ServiceScope;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* The {@link GeminiActions} class provides Thing Actions for the Gemini binding.
|
||||
*
|
||||
* @author Florian Hotze - Initial contribution
|
||||
*/
|
||||
@Component(scope = ServiceScope.PROTOTYPE, service = GeminiActions.class)
|
||||
@ThingActionsScope(name = "gemini")
|
||||
@NonNullByDefault
|
||||
public class GeminiActions implements ThingActions {
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(GeminiActions.class);
|
||||
|
||||
private @Nullable GeminiHandler handler;
|
||||
|
||||
@Override
|
||||
public void setThingHandler(@Nullable ThingHandler handler) {
|
||||
if (handler instanceof GeminiHandler geminiHandler) {
|
||||
this.handler = geminiHandler;
|
||||
} else {
|
||||
this.handler = null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable ThingHandler getThingHandler() {
|
||||
return handler;
|
||||
}
|
||||
|
||||
public @Nullable String sendMessage(@Nullable String prompt) {
|
||||
return sendMessage(prompt, null);
|
||||
}
|
||||
|
||||
public @Nullable String sendMessage(@Nullable String prompt, @Nullable String model) {
|
||||
return sendMessage(prompt, model, null, null, null, null, null);
|
||||
}
|
||||
|
||||
@RuleAction(label = "@text/action.sendMessage.label", description = "@text/action.sendMessage.description")
|
||||
public @Nullable @ActionOutput(label = "@text/action.sendMessage.result.label", description = "@text/action.sendMessage.result.description", type = "java.lang.String") String sendMessage(
|
||||
@ActionInput(name = "prompt", label = "@text/action.sendMessage.prompt.label", description = "@text/action.sendMessage.prompt.description", type = "java.lang.String", required = true) @Nullable String prompt,
|
||||
@ActionInput(name = "model", label = "@text/action.sendMessage.model.label", description = "@text/action.sendMessage.model.description", type = "java.lang.String") @Nullable String model,
|
||||
@ActionInput(name = "systemMessage", label = "@text/action.sendMessage.systemMessage.label", description = "@text/action.sendMessage.systemMessage.description", type = "java.lang.String") @Nullable String systemMessage,
|
||||
@ActionInput(name = "temperature", label = "@text/action.sendMessage.temperature.label", description = "@text/action.sendMessage.temperature.description", type = "java.lang.Double") @Nullable Double temperature,
|
||||
@ActionInput(name = "topP", label = "@text/action.sendMessage.topP.label", description = "@text/action.sendMessage.topP.description", type = "java.lang.Double") @Nullable Double topP,
|
||||
@ActionInput(name = "maxOutputTokens", label = "@text/action.sendMessage.maxOutputTokens.label", description = "@text/action.sendMessage.maxOutputTokens.description", type = "java.lang.Integer") @Nullable Integer maxOutputTokens,
|
||||
@ActionInput(name = "requestTimeout", label = "@text/action.sendMessage.requestTimeout.label", description = "@text/action.sendMessage.requestTimeout.description", type = "java.lang.Integer") @Nullable Integer requestTimeout) {
|
||||
if (prompt == null || prompt.isBlank()) {
|
||||
logger.warn("Cannot send message: prompt is null or blank.");
|
||||
return null;
|
||||
}
|
||||
|
||||
GeminiHandler geminiHandler = handler;
|
||||
if (geminiHandler == null) {
|
||||
logger.warn("Cannot send message: Gemini handler is not initialized.");
|
||||
return null;
|
||||
}
|
||||
GeminiApiClient apiClient = geminiHandler.getApiClient();
|
||||
if (apiClient == null) {
|
||||
logger.warn("Cannot send message: Gemini API client is not initialized.");
|
||||
return null;
|
||||
}
|
||||
|
||||
GeminiConfiguration config = geminiHandler.getGeminiConfiguration();
|
||||
|
||||
String resolvedModel = (model != null && !model.isBlank()) ? model
|
||||
: ((config != null && !config.model.isBlank()) ? config.model : DEFAULT_MODEL);
|
||||
String resolvedSystemMessage = Objects.requireNonNullElse(systemMessage, DEFAULT_SYSTEM_MESSAGE);
|
||||
double resolvedTemperature = Objects.requireNonNullElse(temperature,
|
||||
config != null ? config.temperature : DEFAULT_TEMPERATURE);
|
||||
double resolvedTopP = Objects.requireNonNullElse(topP, config != null ? config.topP : DEFAULT_TOP_P);
|
||||
int resolvedMaxOutputTokens = Objects.requireNonNullElse(maxOutputTokens,
|
||||
config != null ? config.maxOutputTokens : DEFAULT_MAX_OUTPUT_TOKENS);
|
||||
int resolvedRequestTimeout = Objects.requireNonNullElse(requestTimeout,
|
||||
config != null ? config.requestTimeout : DEFAULT_REQUEST_TIMEOUT);
|
||||
|
||||
try {
|
||||
GeminiResponse response = apiClient.sendPrompt(resolvedModel, prompt, resolvedSystemMessage,
|
||||
resolvedTemperature, resolvedTopP, resolvedMaxOutputTokens, resolvedRequestTimeout);
|
||||
String text = response.getFirstText();
|
||||
if (text != null) {
|
||||
return text;
|
||||
}
|
||||
logger.warn("Didn't receive any chat response candidates from Gemini - this is unexpected.");
|
||||
} catch (GeminiApiException e) {
|
||||
logger.debug("Request to Gemini via action failed: {}", e.getMessage(), e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static @Nullable String sendMessage(ThingActions actions, @Nullable String prompt) {
|
||||
if (!(actions instanceof GeminiActions geminiActions)) {
|
||||
throw new IllegalArgumentException("The 'actions' argument is not an instance of GeminiActions");
|
||||
}
|
||||
return geminiActions.sendMessage(prompt);
|
||||
}
|
||||
|
||||
public static @Nullable String sendMessage(ThingActions actions, @Nullable String prompt, @Nullable String model) {
|
||||
if (!(actions instanceof GeminiActions geminiActions)) {
|
||||
throw new IllegalArgumentException("The 'actions' argument is not an instance of GeminiActions");
|
||||
}
|
||||
return geminiActions.sendMessage(prompt, model);
|
||||
}
|
||||
|
||||
public static @Nullable String sendMessage(ThingActions actions, @Nullable String prompt, @Nullable String model,
|
||||
@Nullable String systemMessage, @Nullable Double temperature, @Nullable Double topP,
|
||||
@Nullable Integer maxOutputTokens, @Nullable Integer requestTimeout) {
|
||||
if (!(actions instanceof GeminiActions geminiActions)) {
|
||||
throw new IllegalArgumentException("The 'actions' argument is not an instance of GeminiActions");
|
||||
}
|
||||
|
||||
return geminiActions.sendMessage(prompt, model, systemMessage, temperature, topP, maxOutputTokens,
|
||||
requestTimeout);
|
||||
}
|
||||
}
|
||||
+355
@@ -0,0 +1,355 @@
|
||||
/*
|
||||
* 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 static org.openhab.binding.gemini.internal.GeminiBindingConstants.DEFAULT_REQUEST_TIMEOUT;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Queue;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.eclipse.jetty.client.HttpClient;
|
||||
import org.eclipse.jetty.client.api.ContentResponse;
|
||||
import org.eclipse.jetty.client.api.Request;
|
||||
import org.eclipse.jetty.client.util.StringContentProvider;
|
||||
import org.eclipse.jetty.http.HttpHeader;
|
||||
import org.eclipse.jetty.http.HttpMethod;
|
||||
import org.eclipse.jetty.http.HttpStatus;
|
||||
import org.eclipse.jetty.http.MimeTypes;
|
||||
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.GeminiPart;
|
||||
import org.openhab.binding.gemini.internal.api.dto.request.GeminiFunctionDeclaration;
|
||||
import org.openhab.binding.gemini.internal.api.dto.request.GeminiFunctionResponse;
|
||||
import org.openhab.binding.gemini.internal.api.dto.request.GeminiGenerationConfig;
|
||||
import org.openhab.binding.gemini.internal.api.dto.request.GeminiRequest;
|
||||
import org.openhab.binding.gemini.internal.api.dto.request.GeminiSchema;
|
||||
import org.openhab.binding.gemini.internal.api.dto.request.GeminiTool;
|
||||
import org.openhab.binding.gemini.internal.api.dto.response.GeminiModel;
|
||||
import org.openhab.binding.gemini.internal.api.dto.response.GeminiModelsResponse;
|
||||
import org.openhab.binding.gemini.internal.api.dto.response.GeminiResponse;
|
||||
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.LLMToolCall;
|
||||
import org.openhab.core.voice.text.interpreter.llm.LLMToolParam;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude.Include;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* The {@link GeminiApiClient} class encapsulates all HTTP/REST API communications with the Google Gemini API.
|
||||
*
|
||||
* @author Florian Hotze - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class GeminiApiClient {
|
||||
private static final String GEMINI_API_BASE_URL = "https://generativelanguage.googleapis.com/v1beta";
|
||||
private static final String HEADER_API_KEY = "x-goog-api-key";
|
||||
private static final String ROLE_USER = "user";
|
||||
private static final String ROLE_MODEL = "model";
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(GeminiApiClient.class);
|
||||
|
||||
private final HttpClient httpClient;
|
||||
private final String apiKey;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public GeminiApiClient(HttpClient httpClient, String apiKey) {
|
||||
this.httpClient = httpClient;
|
||||
this.apiKey = apiKey;
|
||||
this.objectMapper = new ObjectMapper();
|
||||
this.objectMapper.setDefaultPropertyInclusion(
|
||||
com.fasterxml.jackson.annotation.JsonInclude.Value.construct(Include.NON_NULL, Include.ALWAYS));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a single prompt to the Gemini API and returns the response DTO.
|
||||
*
|
||||
* @param model the Gemini model to use
|
||||
* @param prompt the prompt text
|
||||
* @param systemMessage the system instruction text
|
||||
* @param temperature the temperature config parameter
|
||||
* @param topP the topP config parameter
|
||||
* @param maxOutputTokens the maxOutputTokens config parameter
|
||||
* @param timeoutSeconds request timeout in seconds
|
||||
* @return the deserialized GeminiResponse
|
||||
* @throws GeminiApiException if a communication error, timeout, or parsing error occurs
|
||||
*/
|
||||
public GeminiResponse sendPrompt(String model, String prompt, @Nullable String systemMessage,
|
||||
@Nullable Double temperature, @Nullable Double topP, @Nullable Integer maxOutputTokens,
|
||||
@Nullable Integer timeoutSeconds) throws GeminiApiException {
|
||||
GeminiContent systemInstruction = createSystemInstruction(systemMessage);
|
||||
|
||||
// Contents
|
||||
GeminiPart userPart = new GeminiPart(prompt, null, null, null);
|
||||
GeminiContent userContent = new GeminiContent(ROLE_USER, List.of(userPart));
|
||||
|
||||
// Config
|
||||
GeminiGenerationConfig genConfig = new GeminiGenerationConfig(maxOutputTokens, temperature, topP, null);
|
||||
|
||||
GeminiRequest request = new GeminiRequest(List.of(userContent), systemInstruction, genConfig, null);
|
||||
|
||||
return executeGenerateContentRequest(model, request, timeoutSeconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a conversation history and tools to the Gemini API and returns the response DTO.
|
||||
*
|
||||
* @param model the Gemini model to use
|
||||
* @param history the conversation message history
|
||||
* @param tools the available tools
|
||||
* @param systemMessage the system instruction text
|
||||
* @param temperature the temperature config parameter
|
||||
* @param topP the topP config parameter
|
||||
* @param maxOutputTokens the maxOutputTokens config parameter
|
||||
* @param timeoutSeconds request timeout in seconds
|
||||
* @return the deserialized GeminiResponse
|
||||
* @throws GeminiApiException if a communication error, timeout, or parsing error occurs
|
||||
*/
|
||||
public GeminiResponse sendPrompt(String model, List<Conversation.Message> history, List<LLMTool> tools,
|
||||
@Nullable String systemMessage, @Nullable Double temperature, @Nullable Double topP,
|
||||
@Nullable Integer maxOutputTokens, @Nullable Integer timeoutSeconds) throws GeminiApiException {
|
||||
GeminiContent systemInstruction = createSystemInstruction(systemMessage);
|
||||
|
||||
List<GeminiContent> contents = new ArrayList<>();
|
||||
Queue<String> pendingToolCallNames = new LinkedList<>();
|
||||
|
||||
for (Conversation.Message msg : history) {
|
||||
switch (msg.role()) {
|
||||
case USER: {
|
||||
GeminiPart part = new GeminiPart(msg.content(), null, null, null);
|
||||
contents.add(new GeminiContent(ROLE_USER, List.of(part)));
|
||||
break;
|
||||
}
|
||||
case OPENHAB: {
|
||||
GeminiPart part = new GeminiPart(msg.content(), null, null, null);
|
||||
contents.add(new GeminiContent(ROLE_MODEL, List.of(part)));
|
||||
break;
|
||||
}
|
||||
case TOOL_CALL: {
|
||||
LLMToolCall toolCall = LLMToolCall.fromJson(msg.content());
|
||||
String name = toolCall.tool().replaceAll("[^a-zA-Z0-9_-]", "_");
|
||||
pendingToolCallNames.add(name);
|
||||
GeminiFunctionCall fc = new GeminiFunctionCall(name, toolCall.params());
|
||||
GeminiPart part = new GeminiPart(null, fc, null, null);
|
||||
contents.add(new GeminiContent(ROLE_MODEL, List.of(part)));
|
||||
break;
|
||||
}
|
||||
case TOOL_RETURN: {
|
||||
String name = pendingToolCallNames.poll();
|
||||
if (name == null) {
|
||||
name = "";
|
||||
}
|
||||
GeminiFunctionResponse fr = new GeminiFunctionResponse(name, Map.of("result", msg.content()));
|
||||
GeminiPart part = new GeminiPart(null, null, fr, null);
|
||||
contents.add(new GeminiContent(ROLE_USER, List.of(part)));
|
||||
break;
|
||||
}
|
||||
case THINKING:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
List<GeminiTool> geminiTools = null;
|
||||
if (!tools.isEmpty()) {
|
||||
List<GeminiFunctionDeclaration> functions = new ArrayList<>();
|
||||
for (LLMTool t : tools) {
|
||||
Map<String, GeminiSchema> properties = new HashMap<>();
|
||||
List<String> required = new ArrayList<>();
|
||||
|
||||
for (LLMToolParam p : t.getParamDescriptions(null)) {
|
||||
List<String> enumValues = p.options().isEmpty() ? null : p.options();
|
||||
String typeStr = p.type().name().toLowerCase(Locale.ROOT);
|
||||
GeminiSchema itemsSchema = null;
|
||||
if ("array".equals(typeStr)) {
|
||||
itemsSchema = new GeminiSchema("string");
|
||||
}
|
||||
GeminiSchema prop = new GeminiSchema(typeStr, p.description(), null, null, enumValues, itemsSchema);
|
||||
properties.put(p.name(), prop);
|
||||
if (p.required()) {
|
||||
required.add(p.name());
|
||||
}
|
||||
}
|
||||
|
||||
GeminiSchema params = new GeminiSchema("object", null, properties, required.isEmpty() ? null : required,
|
||||
null, null);
|
||||
|
||||
String fName = t.getUID().replaceAll("[^a-zA-Z0-9_-]", "_");
|
||||
String fDesc = t.getDescription(null);
|
||||
functions.add(new GeminiFunctionDeclaration(fName, fDesc, params));
|
||||
}
|
||||
geminiTools = List.of(new GeminiTool(functions));
|
||||
}
|
||||
|
||||
GeminiGenerationConfig genConfig = new GeminiGenerationConfig(maxOutputTokens, temperature, topP, null);
|
||||
|
||||
GeminiRequest request = new GeminiRequest(contents, systemInstruction, genConfig, geminiTools);
|
||||
|
||||
return executeGenerateContentRequest(model, request, timeoutSeconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes an HTTP POST request to the <code>generateContent</code> endpoint of a Gemini model.
|
||||
*
|
||||
* <p>
|
||||
* If the request fails with 503 Service Unavailable, up to two retries are performed with with an increasing delay.
|
||||
*
|
||||
* @param model the Gemini model to use
|
||||
* @param requestPayload the payload to send
|
||||
* @param timeoutSeconds request timeout in seconds
|
||||
* @return the deserialized GeminiResponse
|
||||
* @throws GeminiApiException if a communication error, timeout, or parsing error occurs
|
||||
*/
|
||||
private GeminiResponse executeGenerateContentRequest(String model, GeminiRequest requestPayload,
|
||||
@Nullable Integer timeoutSeconds) throws GeminiApiException {
|
||||
String url = GEMINI_API_BASE_URL + "/models/" + model + ":generateContent";
|
||||
|
||||
String queryJson;
|
||||
try {
|
||||
queryJson = objectMapper.writeValueAsString(requestPayload);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new GeminiApiException("Failed to serialize Gemini request: " + e.getMessage(), e);
|
||||
}
|
||||
|
||||
int attemptCount = 1;
|
||||
while (true) {
|
||||
Request request = httpClient.newRequest(url).method(HttpMethod.POST)
|
||||
.timeout((Objects.requireNonNullElse(timeoutSeconds, DEFAULT_REQUEST_TIMEOUT)), TimeUnit.SECONDS)
|
||||
.header(HttpHeader.CONTENT_TYPE, MimeTypes.Type.APPLICATION_JSON.asString())
|
||||
.header(HEADER_API_KEY, apiKey)
|
||||
.content(new StringContentProvider(queryJson, StandardCharsets.UTF_8));
|
||||
if (logger.isDebugEnabled()) {
|
||||
try {
|
||||
String prettyJson = objectMapper.writerWithDefaultPrettyPrinter()
|
||||
.writeValueAsString(requestPayload);
|
||||
logger.debug("Request to {} (attempt {}): \n{}", url, attemptCount, prettyJson);
|
||||
} catch (JsonProcessingException e) {
|
||||
logger.debug("Request to {} (attempt {}): {}", url, attemptCount, queryJson);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
ContentResponse response = request.send();
|
||||
if (response.getStatus() == HttpStatus.OK_200) {
|
||||
String responseBody = response.getContentAsString();
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug("Response from {}: {}", url, responseBody);
|
||||
}
|
||||
try {
|
||||
@Nullable
|
||||
GeminiResponse geminiResponse = readNullableValue(responseBody, GeminiResponse.class);
|
||||
if (geminiResponse == null) {
|
||||
throw new GeminiApiException("Failed to parse Gemini response: response was null");
|
||||
}
|
||||
return geminiResponse;
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new GeminiApiException("Failed to parse Gemini response: " + e.getMessage(), e);
|
||||
}
|
||||
} else if (response.getStatus() == HttpStatus.SERVICE_UNAVAILABLE_503 && attemptCount <= 3) {
|
||||
logger.debug("Gemini request failed with 503 Service Unavailable on attempt #{}/3", attemptCount);
|
||||
try {
|
||||
Thread.sleep(1000 * attemptCount);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new GeminiApiException(
|
||||
"Interrupted while waiting to retry Gemini API request: " + e.getMessage(), e);
|
||||
}
|
||||
attemptCount++;
|
||||
} else {
|
||||
logger.debug("Gemini request failed on the final attempt with HTTP {} {}: {}", response.getStatus(),
|
||||
response.getReason(), response.getContentAsString());
|
||||
throw new GeminiApiException("Gemini generateContent request resulted failed with HTTP "
|
||||
+ response.getStatus() + " " + response.getReason());
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new GeminiApiException("Could not connect to Gemini API: " + e.getMessage(), e);
|
||||
} catch (TimeoutException | ExecutionException e) {
|
||||
throw new GeminiApiException("Could not connect to Gemini API: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the list of models supported by the Gemini API key.
|
||||
*
|
||||
* @param timeoutSeconds request timeout in seconds
|
||||
* @return a list of GeminiModel objects
|
||||
* @throws GeminiApiException if a communication error, timeout, or parsing error occurs
|
||||
*/
|
||||
public List<GeminiModel> fetchModels(@Nullable Integer timeoutSeconds) throws GeminiApiException {
|
||||
String modelsUrl = GEMINI_API_BASE_URL + "/models";
|
||||
Request request = httpClient.newRequest(modelsUrl)
|
||||
.timeout(Objects.requireNonNullElse(timeoutSeconds, DEFAULT_REQUEST_TIMEOUT), TimeUnit.SECONDS)
|
||||
.method(HttpMethod.GET).header(HEADER_API_KEY, apiKey);
|
||||
logger.debug("Request to {}: (GET)", modelsUrl);
|
||||
|
||||
try {
|
||||
ContentResponse response = request.send();
|
||||
if (response.getStatus() == HttpStatus.OK_200) {
|
||||
String responseBody = response.getContentAsString();
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug("Response from {}: {}", modelsUrl, responseBody);
|
||||
}
|
||||
try {
|
||||
@Nullable
|
||||
GeminiModelsResponse modelsResponse = readNullableValue(responseBody, GeminiModelsResponse.class);
|
||||
if (modelsResponse == null) {
|
||||
throw new GeminiApiException("Failed to parse models response DTO: response was null");
|
||||
}
|
||||
List<GeminiModel> models = modelsResponse.models();
|
||||
if (models != null) {
|
||||
return models;
|
||||
}
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new GeminiApiException("Failed to parse models response DTO: " + e.getMessage(), e);
|
||||
}
|
||||
} else {
|
||||
throw new GeminiApiException(
|
||||
"Gemini request for models resulted in HTTP status " + response.getStatus());
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new GeminiApiException("Could not retrieve models from Gemini API: " + e.getMessage(), e);
|
||||
} catch (TimeoutException | ExecutionException e) {
|
||||
throw new GeminiApiException("Could not retrieve models from Gemini API: " + e.getMessage(), e);
|
||||
}
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
private @Nullable GeminiContent createSystemInstruction(@Nullable String systemMessage) {
|
||||
if (systemMessage != null && !systemMessage.isBlank()) {
|
||||
GeminiPart sysPart = new GeminiPart(systemMessage, null, null, null);
|
||||
return new GeminiContent(null, List.of(sysPart));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private <T> @Nullable T readNullableValue(String content, Class<T> valueType) throws JsonProcessingException {
|
||||
return objectMapper.readValue(content, valueType);
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* 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.io.Serial;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* The {@link GeminiApiException} class represents an exception thrown during communication
|
||||
* with the Google Gemini API.
|
||||
*
|
||||
* @author Florian Hotze - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class GeminiApiException extends Exception {
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public GeminiApiException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public GeminiApiException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* 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.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
|
||||
/**
|
||||
* The {@link GeminiContent} record represents the structured content of a message in Gemini API requests
|
||||
* and responses, associating a role (e.g., "user" or "model") with a list of content parts.
|
||||
*
|
||||
* @author Florian Hotze - Initial contribution
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@NonNullByDefault
|
||||
public record GeminiContent(@Nullable String role, @Nullable List<GeminiPart> parts) {
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* 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.dto;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
|
||||
/**
|
||||
* The {@link GeminiFunctionCall} record represents a function call request generated by the model,
|
||||
* indicating the function name and arguments to be executed.
|
||||
*
|
||||
* @author Florian Hotze - Initial contribution
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@NonNullByDefault
|
||||
public record GeminiFunctionCall(@Nullable String name, @Nullable Map<String, Object> args) {
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* 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.dto;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.binding.gemini.internal.api.dto.request.GeminiFunctionResponse;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
|
||||
/**
|
||||
* The {@link GeminiPart} record represents a single part of content, which may contain text, a function call,
|
||||
* or a function response.
|
||||
*
|
||||
* @author Florian Hotze - Initial contribution
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@NonNullByDefault
|
||||
public record GeminiPart(@Nullable String text, @Nullable GeminiFunctionCall functionCall,
|
||||
@Nullable GeminiFunctionResponse functionResponse, @Nullable Boolean thought) {
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* 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.dto.request;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
|
||||
/**
|
||||
* The {@link GeminiFunctionDeclaration} record defines a tool's function signature, description,
|
||||
* and parameters schema that the model can request to execute.
|
||||
*
|
||||
* @author Florian Hotze - Initial contribution
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@NonNullByDefault
|
||||
public record GeminiFunctionDeclaration(@Nullable String name, @Nullable String description,
|
||||
@Nullable GeminiSchema parameters) {
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* 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.dto.request;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
|
||||
/**
|
||||
* The {@link GeminiFunctionResponse} record represents the execution result of a function call
|
||||
* sent back to the model as context.
|
||||
*
|
||||
* @author Florian Hotze - Initial contribution
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@NonNullByDefault
|
||||
public record GeminiFunctionResponse(@Nullable String name, @Nullable Map<String, Object> response) {
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* 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.dto.request;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
|
||||
/**
|
||||
* The {@link GeminiGenerationConfig} record contains the configuration parameters for controlling
|
||||
* content generation, such as temperature, topP, and max output tokens.
|
||||
*
|
||||
* @author Florian Hotze - Initial contribution
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@NonNullByDefault
|
||||
public record GeminiGenerationConfig(@Nullable Integer maxOutputTokens, @Nullable Double temperature,
|
||||
@Nullable Double topP, @Nullable GeminiThinkingConfig thinkingConfig) {
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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.dto.request;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.binding.gemini.internal.api.dto.GeminiContent;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
|
||||
/**
|
||||
* The {@link GeminiRequest} record represents the payload for a content generation request, including
|
||||
* chat history, system instructions, tools, and generation configurations.
|
||||
*
|
||||
* @author Florian Hotze - Initial contribution
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@NonNullByDefault
|
||||
public record GeminiRequest(@Nullable List<GeminiContent> contents, @Nullable GeminiContent systemInstruction,
|
||||
@Nullable GeminiGenerationConfig generationConfig, @Nullable List<GeminiTool> tools) {
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* 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.dto.request;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude.Include;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/**
|
||||
* The {@link GeminiSchema} record represents a JSON schema definition for tool parameters and properties.
|
||||
*
|
||||
* @author Florian Hotze - Initial contribution
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@JsonInclude(Include.NON_NULL)
|
||||
@NonNullByDefault
|
||||
public record GeminiSchema(String type, @Nullable String description, @Nullable Map<String, GeminiSchema> properties,
|
||||
@Nullable List<String> required, @JsonProperty("enum") @Nullable List<String> enumValues,
|
||||
@Nullable GeminiSchema items) {
|
||||
|
||||
public GeminiSchema(String type) {
|
||||
this(type, null, null, null, null, null);
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* 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.dto.request;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
|
||||
/**
|
||||
* The {@link GeminiThinkingConfig} record contains the parameters for controlling the thinking behavior
|
||||
* of Gemini models that support reasoning.
|
||||
*
|
||||
* @author Florian Hotze - Initial contribution
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@NonNullByDefault
|
||||
public record GeminiThinkingConfig(@Nullable Boolean includeThoughts, @Nullable Integer thinkingBudget,
|
||||
@Nullable String thinkingLevel) {
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* 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.dto.request;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
|
||||
/**
|
||||
* The {@link GeminiTool} record defines the list of functions/declarations made available to the model.
|
||||
*
|
||||
* @author Florian Hotze - Initial contribution
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@NonNullByDefault
|
||||
public record GeminiTool(@Nullable List<GeminiFunctionDeclaration> functionDeclarations) {
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* 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.dto.response;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.binding.gemini.internal.api.dto.GeminiContent;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
|
||||
/**
|
||||
* The {@link GeminiCandidate} record represents a single candidate generation/response from the Gemini model.
|
||||
* It contains the generated content and the reason generation finished.
|
||||
*
|
||||
* @author Florian Hotze - Initial contribution
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@NonNullByDefault
|
||||
public record GeminiCandidate(@Nullable GeminiContent content, @Nullable String finishReason) {
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* 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.dto.response;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
|
||||
/**
|
||||
* The {@link GeminiModel} record represents details of a model returned from the models list endpoint.
|
||||
*
|
||||
* @author Florian Hotze - Initial contribution
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@NonNullByDefault
|
||||
public record GeminiModel(@Nullable String name, @Nullable String version, @Nullable String displayName,
|
||||
@Nullable String description, @Nullable List<String> supportedGenerationMethods) {
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* 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.dto.response;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
|
||||
/**
|
||||
* The {@link GeminiModelsResponse} record represents the response payload containing the list of available
|
||||
* Gemini models.
|
||||
*
|
||||
* @author Florian Hotze - Initial contribution
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@NonNullByDefault
|
||||
public record GeminiModelsResponse(@Nullable List<GeminiModel> models) {
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* 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.dto.response;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
|
||||
/**
|
||||
* The {@link GeminiResponse} record represents the response payload for a content generation request,
|
||||
* containing the candidates generated by the model and usage metadata.
|
||||
*
|
||||
* @author Florian Hotze - Initial contribution
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@NonNullByDefault
|
||||
public record GeminiResponse(@Nullable List<GeminiCandidate> candidates, @Nullable GeminiUsageMetadata usageMetadata) {
|
||||
|
||||
public @Nullable String getFirstText() {
|
||||
List<GeminiCandidate> candidatesList = candidates;
|
||||
if (candidatesList != null && !candidatesList.isEmpty()) {
|
||||
GeminiCandidate candidate = candidatesList.getFirst();
|
||||
var content = candidate.content();
|
||||
if (content != null) {
|
||||
var parts = content.parts();
|
||||
if (parts != null && !parts.isEmpty()) {
|
||||
var part = parts.getFirst();
|
||||
return part.text();
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* 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.dto.response;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
|
||||
/**
|
||||
* The {@link GeminiUsageMetadata} record represents token usage metadata for the request and response.
|
||||
*
|
||||
* @author Florian Hotze - Initial contribution
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@NonNullByDefault
|
||||
public record GeminiUsageMetadata(@Nullable Integer promptTokenCount, @Nullable Integer candidatesTokenCount,
|
||||
@Nullable Integer totalTokenCount, @Nullable Integer thoughtsTokenCount) {
|
||||
}
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
/*
|
||||
* 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.hli;
|
||||
|
||||
import static org.openhab.binding.gemini.internal.GeminiBindingConstants.BINDING_ID;
|
||||
import static org.openhab.binding.gemini.internal.GeminiBindingConstants.DEFAULT_MODEL;
|
||||
import static org.openhab.binding.gemini.internal.GeminiBindingConstants.DEFAULT_SYSTEM_MESSAGE;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.binding.gemini.internal.GeminiConfiguration;
|
||||
import org.openhab.binding.gemini.internal.GeminiHandler;
|
||||
import org.openhab.binding.gemini.internal.api.GeminiApiClient;
|
||||
import org.openhab.binding.gemini.internal.api.GeminiApiException;
|
||||
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.GeminiPart;
|
||||
import org.openhab.binding.gemini.internal.api.dto.response.GeminiCandidate;
|
||||
import org.openhab.binding.gemini.internal.api.dto.response.GeminiResponse;
|
||||
import org.openhab.core.thing.binding.ThingHandler;
|
||||
import org.openhab.core.thing.binding.ThingHandlerService;
|
||||
import org.openhab.core.voice.text.HumanLanguageInterpreter;
|
||||
import org.openhab.core.voice.text.InterpretationException;
|
||||
import org.openhab.core.voice.text.InterpreterContext;
|
||||
import org.openhab.core.voice.text.conversation.Conversation;
|
||||
import org.openhab.core.voice.text.conversation.ConversationException;
|
||||
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.LLMToolCall;
|
||||
import org.openhab.core.voice.text.interpreter.llm.LLMToolException;
|
||||
import org.osgi.service.component.annotations.Component;
|
||||
|
||||
/**
|
||||
* The {@link GeminiHLIService} is a {@link HumanLanguageInterpreter} implementation based on Google Gemini.
|
||||
*
|
||||
* @author Florian Hotze - Initial contribution
|
||||
*/
|
||||
@Component(service = { GeminiHLIService.class, HumanLanguageInterpreter.class })
|
||||
@NonNullByDefault
|
||||
public class GeminiHLIService implements ThingHandlerService, HumanLanguageInterpreter {
|
||||
private static final String LABEL = "Gemini Human Language Interpreter";
|
||||
|
||||
private @Nullable GeminiHandler handler;
|
||||
|
||||
@Override
|
||||
public void setThingHandler(@Nullable ThingHandler handler) {
|
||||
if (handler instanceof GeminiHandler geminiHandler) {
|
||||
this.handler = geminiHandler;
|
||||
} else {
|
||||
this.handler = null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable ThingHandler getThingHandler() {
|
||||
return handler;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return BINDING_ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getLabel(@Nullable Locale locale) {
|
||||
return LABEL;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<Locale> getSupportedLocales() {
|
||||
return Set.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> getSupportedGrammarFormats() {
|
||||
return Set.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable String getGrammar(Locale locale, String format) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String interpret(Locale locale, String text) throws InterpretationException {
|
||||
GeminiHandler geminiHandler = handler;
|
||||
if (geminiHandler == null) {
|
||||
throw new InterpretationException("GeminiHandler is not initialized");
|
||||
}
|
||||
GeminiApiClient apiClient = geminiHandler.getApiClient();
|
||||
if (apiClient == null) {
|
||||
throw new InterpretationException("Gemini API client is not available");
|
||||
}
|
||||
GeminiConfiguration config = geminiHandler.getGeminiConfiguration();
|
||||
if (config == null) {
|
||||
throw new InterpretationException("Gemini HLI configuration is not available");
|
||||
}
|
||||
String model = config.model.isBlank() ? DEFAULT_MODEL : config.model;
|
||||
try {
|
||||
GeminiResponse geminiResponse = apiClient.sendPrompt(model, text, DEFAULT_SYSTEM_MESSAGE,
|
||||
config.temperature, config.topP, config.maxOutputTokens, config.requestTimeout);
|
||||
String response = geminiResponse.getFirstText();
|
||||
if (response != null) {
|
||||
return response;
|
||||
}
|
||||
} catch (GeminiApiException e) {
|
||||
var ex = new InterpretationException("Failed to get response from Gemini: " + e.getMessage());
|
||||
ex.initCause(e);
|
||||
throw ex;
|
||||
}
|
||||
throw new InterpretationException("No valid response received");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String interpret(Locale locale, InterpreterContext interpreterContext) throws InterpretationException {
|
||||
GeminiHandler geminiHandler = handler;
|
||||
if (geminiHandler == null) {
|
||||
throw new InterpretationException("GeminiHandler is not initialized");
|
||||
}
|
||||
GeminiApiClient apiClient = geminiHandler.getApiClient();
|
||||
if (apiClient == null) {
|
||||
throw new InterpretationException("Gemini API client is not available");
|
||||
}
|
||||
|
||||
GeminiConfiguration config = geminiHandler.getGeminiConfiguration();
|
||||
if (config == null) {
|
||||
throw new InterpretationException("Gemini HLI configuration is not available");
|
||||
}
|
||||
|
||||
Conversation conversation = interpreterContext.conversation();
|
||||
List<LLMTool> tools = interpreterContext.tools();
|
||||
|
||||
String systemMessage = interpreterContext.systemPrompt();
|
||||
if (systemMessage == null || systemMessage.isBlank()) {
|
||||
throw new InterpretationException("System prompt is missing or empty");
|
||||
}
|
||||
String locationItem = interpreterContext.locationItem();
|
||||
if (locationItem != null && !locationItem.isBlank()) {
|
||||
systemMessage = systemMessage.trim() + "\n\n" + "Your location (item name): " + locationItem;
|
||||
}
|
||||
if (!tools.isEmpty()) {
|
||||
String toolGuidance = """
|
||||
You have tools available to interact with the environment. Use them when appropriate.
|
||||
However, if the user's request cannot be fulfilled by any tool, or if they ask a general question,
|
||||
answer them directly using your general knowledge.
|
||||
Do not try to force a tool call. Do not state that you can only perform actions supported by the tools.
|
||||
When using a tool, always provide a clear and concise natural language response.
|
||||
""";
|
||||
systemMessage = systemMessage.trim() + "\n\n" + toolGuidance;
|
||||
}
|
||||
|
||||
int loopCount = 0;
|
||||
final int maxLoops = 10;
|
||||
while (true) {
|
||||
if (loopCount >= maxLoops) {
|
||||
throw new InterpretationException(
|
||||
"Tool execution loop limit exceeded (max " + maxLoops + " iterations)");
|
||||
}
|
||||
loopCount++;
|
||||
String model = config.model.isBlank() ? DEFAULT_MODEL : config.model;
|
||||
try {
|
||||
GeminiResponse geminiResponse = apiClient.sendPrompt(model, conversation.getMessages(), tools,
|
||||
systemMessage, config.temperature, config.topP, config.maxOutputTokens, config.requestTimeout);
|
||||
|
||||
List<GeminiCandidate> candidates = geminiResponse.candidates();
|
||||
if (candidates == null || candidates.isEmpty()) {
|
||||
throw new InterpretationException("No valid response received");
|
||||
}
|
||||
GeminiContent responseContent = candidates.getFirst().content();
|
||||
if (responseContent == null) {
|
||||
throw new InterpretationException("No valid response received");
|
||||
}
|
||||
List<GeminiPart> parts = responseContent.parts();
|
||||
if (parts == null) {
|
||||
throw new InterpretationException("No valid response received");
|
||||
}
|
||||
|
||||
boolean hasToolCall = false;
|
||||
StringBuilder textBuilder = new StringBuilder();
|
||||
|
||||
for (GeminiPart part : parts) {
|
||||
GeminiFunctionCall fc = part.functionCall();
|
||||
if (fc != null) {
|
||||
hasToolCall = true;
|
||||
String toolName = fc.name();
|
||||
Map<String, Object> args = fc.args();
|
||||
|
||||
LLMToolCall llmToolCall = new LLMToolCall(toolName != null ? toolName : "",
|
||||
args != null ? args : new HashMap<>());
|
||||
try {
|
||||
conversation.addMessage(ConversationRole.TOOL_CALL, llmToolCall.toJson());
|
||||
} catch (ConversationException e) {
|
||||
var ex = new InterpretationException("Failed to add TOOL_CALL to conversation");
|
||||
ex.initCause(e);
|
||||
throw ex;
|
||||
}
|
||||
|
||||
String result = executeTool(tools, toolName, args, locale);
|
||||
|
||||
try {
|
||||
conversation.addMessage(ConversationRole.TOOL_RETURN, result);
|
||||
} catch (ConversationException e) {
|
||||
var ex = new InterpretationException("Failed to add TOOL_RETURN to conversation");
|
||||
ex.initCause(e);
|
||||
throw ex;
|
||||
}
|
||||
} else if (part.text() != null) {
|
||||
textBuilder.append(part.text());
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasToolCall) {
|
||||
String finalResponse = textBuilder.toString();
|
||||
try {
|
||||
conversation.addMessage(ConversationRole.OPENHAB, finalResponse);
|
||||
} catch (ConversationException e) {
|
||||
var ex = new InterpretationException("Failed to add OPENHAB message to conversation");
|
||||
ex.initCause(e);
|
||||
throw ex;
|
||||
}
|
||||
return finalResponse;
|
||||
}
|
||||
} catch (GeminiApiException e) {
|
||||
InterpretationException ex = new InterpretationException(
|
||||
"Failed to communicate with Gemini: " + e.getMessage());
|
||||
ex.initCause(e);
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String executeTool(List<LLMTool> tools, @Nullable String toolName, @Nullable Map<String, Object> args,
|
||||
Locale locale) {
|
||||
if (toolName == null) {
|
||||
return "Error: Tool name is null";
|
||||
}
|
||||
LLMTool tool = tools.stream().filter(t -> t.getUID().replaceAll("[^a-zA-Z0-9_-]", "_").equals(toolName))
|
||||
.findFirst().orElse(null);
|
||||
if (tool == null) {
|
||||
return "Error: Tool " + toolName + " not found";
|
||||
}
|
||||
try {
|
||||
return tool.call(args != null ? args : new HashMap<>(), locale);
|
||||
} catch (LLMToolException e) {
|
||||
return "Error: " + e.getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<addon:addon id="gemini" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns:addon="https://openhab.org/schemas/addon/v1.0.0"
|
||||
xsi:schemaLocation="https://openhab.org/schemas/addon/v1.0.0 https://openhab.org/schemas/addon-1.0.0.xsd">
|
||||
|
||||
<type>binding</type>
|
||||
<name>Gemini Binding</name>
|
||||
<description>Integrates with the Google Gemini API for AI-powered chat and smart home control.</description>
|
||||
<connection>cloud</connection>
|
||||
|
||||
</addon:addon>
|
||||
@@ -0,0 +1,73 @@
|
||||
# add-on
|
||||
|
||||
addon.gemini.name = Gemini Binding
|
||||
addon.gemini.description = Integrates with the Google Gemini API for AI-powered chat and smart home control.
|
||||
|
||||
# thing types
|
||||
|
||||
thing-type.gemini.account.label = Gemini Account
|
||||
thing-type.gemini.account.description = Integrates the Google Gemini API with openHAB.
|
||||
|
||||
# thing types config
|
||||
|
||||
thing-type.config.gemini.account.apiKey.label = API Key
|
||||
thing-type.config.gemini.account.apiKey.description = The Google Gemini API key (Create & Manage API keys in <a href="https://aistudio.google.com/api-keys">Google AI Studio</a>).
|
||||
thing-type.config.gemini.account.group.authentication.label = Authentication
|
||||
thing-type.config.gemini.account.group.connection.label = Connection
|
||||
thing-type.config.gemini.account.group.hli.label = Model Configuration
|
||||
thing-type.config.gemini.account.group.hli.description = Configures the used model and its behavior. Applies to the Human Language Interpreter and as a default for all channels and Thing actions.
|
||||
thing-type.config.gemini.account.maxOutputTokens.label = Max Output Tokens
|
||||
thing-type.config.gemini.account.maxOutputTokens.description = The maximum number of tokens to generate in the response.
|
||||
thing-type.config.gemini.account.model.label = Model
|
||||
thing-type.config.gemini.account.model.description = The Google Gemini model to use (<a href="https://ai.google.dev/gemini-api/docs/models">Models</a>, <a href="https://ai.google.dev/gemini-api/docs/pricing">Pricing</a>).
|
||||
thing-type.config.gemini.account.requestTimeout.label = Request Timeout
|
||||
thing-type.config.gemini.account.requestTimeout.description = The timeout in seconds for API requests.
|
||||
thing-type.config.gemini.account.temperature.label = Temperature
|
||||
thing-type.config.gemini.account.temperature.description = Controls randomness. Higher values mean more random responses.
|
||||
thing-type.config.gemini.account.topP.label = Top-P
|
||||
thing-type.config.gemini.account.topP.description = Controls the cumulative probability threshold for token selection.
|
||||
|
||||
# channel types
|
||||
|
||||
channel-type.gemini.chat.label = Chat
|
||||
channel-type.gemini.chat.description = Send a prompt to a model and receive a response.
|
||||
|
||||
# channel types config
|
||||
|
||||
channel-type.config.gemini.chat.maxOutputTokens.label = Max Output Tokens
|
||||
channel-type.config.gemini.chat.maxOutputTokens.description = The maximum number of tokens to generate in the response.
|
||||
channel-type.config.gemini.chat.model.label = Model
|
||||
channel-type.config.gemini.chat.model.description = The Gemini model to use.
|
||||
channel-type.config.gemini.chat.systemMessage.label = System Message
|
||||
channel-type.config.gemini.chat.systemMessage.description = The system instruction provided to the model.
|
||||
channel-type.config.gemini.chat.temperature.label = Temperature
|
||||
channel-type.config.gemini.chat.temperature.description = Controls randomness. Higher values mean more random responses.
|
||||
channel-type.config.gemini.chat.topP.label = Top-P
|
||||
channel-type.config.gemini.chat.topP.description = Controls the cumulative probability threshold for token selection.
|
||||
|
||||
# actions
|
||||
|
||||
action.sendMessage.label = Send Message
|
||||
action.sendMessage.description = Sends a message to Gemini and returns the response.
|
||||
action.sendMessage.prompt.label = Prompt
|
||||
action.sendMessage.prompt.description = The prompt message to send to Gemini.
|
||||
action.sendMessage.model.label = Model
|
||||
action.sendMessage.model.description = The specific Gemini model to use for this message.
|
||||
action.sendMessage.systemMessage.label = System Message
|
||||
action.sendMessage.systemMessage.description = The system instruction provided to the model.
|
||||
action.sendMessage.temperature.label = Temperature
|
||||
action.sendMessage.temperature.description = The temperature setting to control randomness.
|
||||
action.sendMessage.topP.label = Top P
|
||||
action.sendMessage.topP.description = Controls the cumulative probability threshold for token selection.
|
||||
action.sendMessage.maxOutputTokens.label = Max Output Tokens
|
||||
action.sendMessage.maxOutputTokens.description = The maximum number of tokens to generate in the response.
|
||||
action.sendMessage.requestTimeout.label = Request Timeout
|
||||
action.sendMessage.requestTimeout.description = The timeout in seconds for this request.
|
||||
action.sendMessage.result.label = Response
|
||||
action.sendMessage.result.description = The text response returned by Gemini.
|
||||
|
||||
# thing status
|
||||
|
||||
offline.configuration-error = No API key configured
|
||||
requestTimeout.configuration-error = Timeout must be greater than zero
|
||||
offline.communication-error = Could not connect to Gemini API
|
||||
@@ -0,0 +1,112 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<thing:thing-descriptions bindingId="gemini"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
|
||||
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
|
||||
|
||||
<thing-type id="account" extensible="chat">
|
||||
<label>Gemini Account</label>
|
||||
<description>Integrates the Google Gemini API with openHAB.</description>
|
||||
|
||||
<channels>
|
||||
<channel id="chat" typeId="chat"/>
|
||||
</channels>
|
||||
|
||||
<config-description>
|
||||
<parameter-group name="authentication">
|
||||
<context>network-security</context>
|
||||
<label>Authentication</label>
|
||||
</parameter-group>
|
||||
<parameter-group name="hli">
|
||||
<label>Model Configuration</label>
|
||||
<description>Configures the used model and its behavior. Applies to the Human Language Interpreter and as a default
|
||||
for all channels and Thing actions.</description>
|
||||
</parameter-group>
|
||||
<parameter-group name="connection">
|
||||
<context>network</context>
|
||||
<label>Connection</label>
|
||||
</parameter-group>
|
||||
|
||||
<!-- Authentication Settings -->
|
||||
<parameter name="apiKey" type="text" required="true" groupName="authentication">
|
||||
<context>password</context>
|
||||
<label>API Key</label>
|
||||
<description>
|
||||
<![CDATA[
|
||||
The Google Gemini API key (Create & Manage API keys in <a href="https://aistudio.google.com/api-keys">Google AI Studio</a>).
|
||||
]]>
|
||||
</description>
|
||||
</parameter>
|
||||
<!-- HLI Settings -->
|
||||
<parameter name="model" type="text" groupName="hli">
|
||||
<label>Model</label>
|
||||
<description>
|
||||
<![CDATA[
|
||||
The Google Gemini model to use (<a href="https://ai.google.dev/gemini-api/docs/models">Models</a>, <a href="https://ai.google.dev/gemini-api/docs/pricing">Pricing</a>).
|
||||
]]>
|
||||
</description>
|
||||
<default>gemini-2.5-flash</default>
|
||||
</parameter>
|
||||
<parameter name="temperature" type="decimal" groupName="hli" min="0.0" max="1.0">
|
||||
<label>Temperature</label>
|
||||
<description>Controls randomness. Higher values mean more random responses.</description>
|
||||
<default>1.0</default>
|
||||
<advanced>true</advanced>
|
||||
</parameter>
|
||||
<parameter name="topP" type="decimal" groupName="hli" min="0.0" max="1.0">
|
||||
<label>Top-P</label>
|
||||
<description>Controls the cumulative probability threshold for token selection.</description>
|
||||
<default>1.0</default>
|
||||
<advanced>true</advanced>
|
||||
</parameter>
|
||||
<parameter name="maxOutputTokens" type="integer" groupName="hli" min="1">
|
||||
<label>Max Output Tokens</label>
|
||||
<description>The maximum number of tokens to generate in the response.</description>
|
||||
<default>2048</default>
|
||||
<advanced>true</advanced>
|
||||
</parameter>
|
||||
<!-- Connection Settings -->
|
||||
<parameter name="requestTimeout" type="integer" min="1" unit="s" groupName="connection">
|
||||
<label>Request Timeout</label>
|
||||
<description>The timeout in seconds for API requests.</description>
|
||||
<default>30</default>
|
||||
<advanced>true</advanced>
|
||||
</parameter>
|
||||
</config-description>
|
||||
</thing-type>
|
||||
|
||||
<channel-type id="chat">
|
||||
<item-type>String</item-type>
|
||||
<label>Chat</label>
|
||||
<description>Send a prompt to a model and receive a response.</description>
|
||||
<autoUpdatePolicy>veto</autoUpdatePolicy>
|
||||
<config-description>
|
||||
<parameter name="model" type="text">
|
||||
<label>Model</label>
|
||||
<description>The Gemini model to use.</description>
|
||||
</parameter>
|
||||
<parameter name="temperature" type="decimal" min="0.0" max="2.0">
|
||||
<label>Temperature</label>
|
||||
<description>Controls randomness. Higher values mean more random responses.</description>
|
||||
<advanced>true</advanced>
|
||||
</parameter>
|
||||
<parameter name="topP" type="decimal" min="0.0" max="1.0">
|
||||
<label>Top-P</label>
|
||||
<description>Controls the cumulative probability threshold for token selection.</description>
|
||||
<advanced>true</advanced>
|
||||
</parameter>
|
||||
<parameter name="maxOutputTokens" type="integer" min="1">
|
||||
<label>Max Output Tokens</label>
|
||||
<description>The maximum number of tokens to generate in the response.</description>
|
||||
<advanced>true</advanced>
|
||||
</parameter>
|
||||
<parameter name="systemMessage" type="text">
|
||||
<label>System Message</label>
|
||||
<description>The system instruction provided to the model.</description>
|
||||
<default>You are a helpful assistant.</default>
|
||||
<advanced>false</advanced>
|
||||
</parameter>
|
||||
</config-description>
|
||||
</channel-type>
|
||||
|
||||
</thing:thing-descriptions>
|
||||
@@ -181,6 +181,7 @@
|
||||
<module>org.openhab.binding.ftpupload</module>
|
||||
<module>org.openhab.binding.gardena</module>
|
||||
<module>org.openhab.binding.gce</module>
|
||||
<module>org.openhab.binding.gemini</module>
|
||||
<module>org.openhab.binding.generacmobilelink</module>
|
||||
<module>org.openhab.binding.goecharger</module>
|
||||
<module>org.openhab.binding.govee</module>
|
||||
|
||||
Reference in New Issue
Block a user