This is an automated email from the ASF dual-hosted git repository.
wenjin272 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/flink-agents.git
The following commit(s) were added to refs/heads/main by this push:
new 8a4dd40a [feature][integration][java] Add built-in Google Gemini chat
model integration (#718)
8a4dd40a is described below
commit 8a4dd40ae42d3ec301d211d35de96d676014118e
Author: Tachikoma <[email protected]>
AuthorDate: Wed Jul 1 16:41:41 2026 +0800
[feature][integration][java] Add built-in Google Gemini chat model
integration (#718)
---
.../flink/agents/api/resource/ResourceName.java | 6 +
dist/pom.xml | 5 +
integrations/{ => chat-models/gemini}/pom.xml | 35 +-
.../gemini/GeminiChatModelConnection.java | 542 +++++++++++++++++++++
.../chatmodels/gemini/GeminiChatModelSetup.java | 150 ++++++
.../gemini/GeminiChatModelConnectionTest.java | 346 +++++++++++++
.../gemini/GeminiChatModelSetupTest.java | 99 ++++
integrations/chat-models/pom.xml | 1 +
integrations/pom.xml | 1 +
python/flink_agents/api/resource.py | 4 +
10 files changed, 1171 insertions(+), 18 deletions(-)
diff --git
a/api/src/main/java/org/apache/flink/agents/api/resource/ResourceName.java
b/api/src/main/java/org/apache/flink/agents/api/resource/ResourceName.java
index 1798b996..b1fe9df0 100644
--- a/api/src/main/java/org/apache/flink/agents/api/resource/ResourceName.java
+++ b/api/src/main/java/org/apache/flink/agents/api/resource/ResourceName.java
@@ -65,6 +65,12 @@ public final class ResourceName {
public static final String BEDROCK_SETUP =
"org.apache.flink.agents.integrations.chatmodels.bedrock.BedrockChatModelSetup";
+ // Gemini
+ public static final String GEMINI_CONNECTION =
+
"org.apache.flink.agents.integrations.chatmodels.gemini.GeminiChatModelConnection";
+ public static final String GEMINI_SETUP =
+
"org.apache.flink.agents.integrations.chatmodels.gemini.GeminiChatModelSetup";
+
// Ollama
public static final String OLLAMA_CONNECTION =
"org.apache.flink.agents.integrations.chatmodels.ollama.OllamaChatModelConnection";
diff --git a/dist/pom.xml b/dist/pom.xml
index 0a72542a..47d393ed 100644
--- a/dist/pom.xml
+++ b/dist/pom.xml
@@ -85,6 +85,11 @@ under the License.
<artifactId>flink-agents-integrations-chat-models-bedrock</artifactId>
<version>${project.version}</version>
</dependency>
+ <dependency>
+ <groupId>org.apache.flink</groupId>
+
<artifactId>flink-agents-integrations-chat-models-gemini</artifactId>
+ <version>${project.version}</version>
+ </dependency>
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>flink-agents-integrations-embedding-models-ollama</artifactId>
diff --git a/integrations/pom.xml b/integrations/chat-models/gemini/pom.xml
similarity index 62%
copy from integrations/pom.xml
copy to integrations/chat-models/gemini/pom.xml
index 75404881..d9942014 100644
--- a/integrations/pom.xml
+++ b/integrations/chat-models/gemini/pom.xml
@@ -22,28 +22,27 @@ under the License.
<parent>
<groupId>org.apache.flink</groupId>
- <artifactId>flink-agents</artifactId>
+ <artifactId>flink-agents-integrations-chat-models</artifactId>
<version>0.3-SNAPSHOT</version>
+ <relativePath>../pom.xml</relativePath>
</parent>
- <artifactId>flink-agents-integrations</artifactId>
- <name>Flink Agents : Integrations:</name>
- <packaging>pom</packaging>
+ <artifactId>flink-agents-integrations-chat-models-gemini</artifactId>
+ <name>Flink Agents : Integrations: Chat Models: Google Gemini</name>
+ <packaging>jar</packaging>
- <properties>
- <ollama4j.version>1.1.5</ollama4j.version>
- <elasticsearch.version>8.19.0</elasticsearch.version>
- <milvus.version>2.6.18</milvus.version>
- <openai.version>4.8.0</openai.version>
- <anthropic.version>2.11.1</anthropic.version>
- <aws.sdk.version>2.32.16</aws.sdk.version>
- </properties>
+ <dependencies>
+ <dependency>
+ <groupId>org.apache.flink</groupId>
+ <artifactId>flink-agents-api</artifactId>
+ <version>${project.version}</version>
+ </dependency>
- <modules>
- <module>chat-models</module>
- <module>embedding-models</module>
- <module>vector-stores</module>
- <module>mcp</module>
- </modules>
+ <dependency>
+ <groupId>com.google.genai</groupId>
+ <artifactId>google-genai</artifactId>
+ <version>${google.genai.version}</version>
+ </dependency>
+ </dependencies>
</project>
diff --git
a/integrations/chat-models/gemini/src/main/java/org/apache/flink/agents/integrations/chatmodels/gemini/GeminiChatModelConnection.java
b/integrations/chat-models/gemini/src/main/java/org/apache/flink/agents/integrations/chatmodels/gemini/GeminiChatModelConnection.java
new file mode 100644
index 00000000..b0fb30df
--- /dev/null
+++
b/integrations/chat-models/gemini/src/main/java/org/apache/flink/agents/integrations/chatmodels/gemini/GeminiChatModelConnection.java
@@ -0,0 +1,542 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.flink.agents.integrations.chatmodels.gemini;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.genai.Client;
+import com.google.genai.types.Candidate;
+import com.google.genai.types.Content;
+import com.google.genai.types.FunctionCall;
+import com.google.genai.types.FunctionDeclaration;
+import com.google.genai.types.GenerateContentConfig;
+import com.google.genai.types.GenerateContentResponse;
+import com.google.genai.types.GenerateContentResponseUsageMetadata;
+import com.google.genai.types.HttpOptions;
+import com.google.genai.types.Part;
+import com.google.genai.types.Tool;
+import org.apache.flink.agents.api.chat.messages.ChatMessage;
+import org.apache.flink.agents.api.chat.messages.MessageRole;
+import org.apache.flink.agents.api.chat.model.BaseChatModelConnection;
+import org.apache.flink.agents.api.resource.ResourceContext;
+import org.apache.flink.agents.api.resource.ResourceDescriptor;
+import org.apache.flink.agents.api.tools.ToolMetadata;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+/**
+ * A chat model integration for the Google Gemini {@code generateContent} API
using the official
+ * google-genai Java SDK.
+ *
+ * <p>The native Gemini protocol differs from the OpenAI-compatible shape in a
few places this
+ * module handles directly:
+ *
+ * <ul>
+ * <li>System messages are passed as a separate {@code systemInstruction},
not a system role.
+ * <li>Conversation roles are {@code user} and {@code model} (assistant maps
to {@code model}).
+ * <li>Tool calls are returned as {@code functionCall} parts carrying a
native {@code id} (there
+ * is no separate {@code tool_call_id}); tool results are sent back as
{@code
+ * functionResponse} parts inside a {@code user} turn.
+ * </ul>
+ *
+ * <p>Supported connection parameters:
+ *
+ * <ul>
+ * <li><b>api_key</b> (optional): Gemini Developer API key. May be omitted
when a local proxy
+ * injects the credential, but either {@code api_key} or {@code
base_url} must be provided.
+ * <li><b>base_url</b> (optional): Custom endpoint, e.g. a local proxy such
as {@code
+ * http://127.0.0.1:15721}. When set, requests are routed there instead
of the default Google
+ * endpoint.
+ * <li><b>model</b> (optional): Default model name, used when no model is
supplied per request.
+ * <li><b>timeout</b> (optional): Timeout in seconds for API requests.
+ * <li><b>vertex_ai</b> (optional): When true, use the Vertex AI backend
together with {@code
+ * project} and {@code location}. The Vertex path wires the SDK builder
flags and is
+ * smoke-tested at construction, but a full end-to-end run against
Vertex is a follow-up.
+ * <li><b>project</b> / <b>location</b> (optional): Vertex AI project id and
location.
+ * </ul>
+ *
+ * <p>Example usage:
+ *
+ * <pre>{@code
+ * public class MyAgent extends Agent {
+ * @ChatModelConnection
+ * public static ResourceDesc gemini() {
+ * return
ResourceDescriptor.Builder.newBuilder(GeminiChatModelConnection.class.getName())
+ * .addInitialArgument("api_key", System.getenv("GEMINI_API_KEY"))
+ * .addInitialArgument("model", "gemini-3.1-pro-preview")
+ * .build();
+ * }
+ * }
+ * }</pre>
+ */
+public class GeminiChatModelConnection extends BaseChatModelConnection {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(GeminiChatModelConnection.class);
+
+ private static final TypeReference<Map<String, Object>> MAP_TYPE = new
TypeReference<>() {};
+
+ private final ObjectMapper mapper = new ObjectMapper();
+ private final Client client;
+ private final String defaultModel;
+
+ public GeminiChatModelConnection(
+ ResourceDescriptor descriptor, ResourceContext resourceContext) {
+ super(descriptor, resourceContext);
+
+ String apiKey = descriptor.getArgument("api_key");
+ String baseUrl = descriptor.getArgument("base_url");
+ Boolean vertexAi = descriptor.getArgument("vertex_ai");
+
+ boolean useVertex = Boolean.TRUE.equals(vertexAi);
+ if (!useVertex
+ && (apiKey == null || apiKey.isBlank())
+ && (baseUrl == null || baseUrl.isBlank())) {
+ throw new IllegalArgumentException(
+ "Either api_key or base_url must be provided for the
Gemini connection.");
+ }
+
+ Client.Builder builder = Client.builder();
+ if (!useVertex) {
+ // The SDK requires a non-blank API key for the Gemini Developer
backend. When the
+ // caller relies on a proxy (base_url) to inject the real
credential, supply a
+ // placeholder so the SDK's own validation passes; the proxy
overrides it on the wire.
+ if (apiKey != null && !apiKey.isBlank()) {
+ builder.apiKey(apiKey);
+ } else {
+ builder.apiKey("proxy-injected");
+ }
+ }
+
+ HttpOptions.Builder httpOptions = null;
+ if (baseUrl != null && !baseUrl.isBlank()) {
+ httpOptions = HttpOptions.builder().baseUrl(baseUrl);
+ }
+ Integer timeoutSeconds = descriptor.getArgument("timeout");
+ if (timeoutSeconds != null && timeoutSeconds > 0) {
+ if (httpOptions == null) {
+ httpOptions = HttpOptions.builder();
+ }
+ // HttpOptions timeout is expressed in milliseconds. Compute in
long to avoid int
+ // overflow for large second values, then clamp to
Integer.MAX_VALUE.
+ long timeoutMs = (long) timeoutSeconds * 1000L;
+ httpOptions.timeout((int) Math.min(timeoutMs, Integer.MAX_VALUE));
+ }
+ if (httpOptions != null) {
+ builder.httpOptions(httpOptions.build());
+ }
+
+ if (useVertex) {
+ builder.vertexAI(true);
+ String project = descriptor.getArgument("project");
+ String location = descriptor.getArgument("location");
+ if (project != null && !project.isBlank()) {
+ builder.project(project);
+ }
+ if (location != null && !location.isBlank()) {
+ builder.location(location);
+ }
+ }
+
+ this.defaultModel = descriptor.getArgument("model");
+ this.client = builder.build();
+ }
+
+ @Override
+ public void close() {
+ this.client.close();
+ }
+
+ @Override
+ public ChatMessage chat(
+ List<ChatMessage> messages,
+ List<org.apache.flink.agents.api.tools.Tool> tools,
+ Map<String, Object> arguments) {
+ Map<String, Object> args = arguments != null ? new
HashMap<>(arguments) : new HashMap<>();
+
+ Object modelObj = args.remove("model");
+ String modelName = modelObj != null ? modelObj.toString() :
this.defaultModel;
+ if (modelName == null || modelName.isBlank()) {
+ modelName = this.defaultModel;
+ }
+ if (modelName == null || modelName.isBlank()) {
+ throw new IllegalArgumentException("model name must be provided
for Gemini.");
+ }
+
+ // ChatModelAction emits TOOL messages with only `externalId` in
extraArgs (matching the
+ // sibling Anthropic/OpenAI connectors). Gemini's functionResponse
part however requires the
+ // function name. Build a tool-call-id -> name lookup from prior
ASSISTANT turns so the TOOL
+ // branch in convertToContent can recover the name from `externalId`.
+ Map<String, String> toolCallIdToName =
buildToolCallIdToNameMap(messages);
+
+ try {
+ List<Content> contents =
+ messages.stream()
+ .filter(m -> m.getRole() != MessageRole.SYSTEM)
+ .map(m -> convertToContent(m, toolCallIdToName))
+ .collect(Collectors.toList());
+
+ GenerateContentConfig config = buildConfig(messages, tools, args);
+
+ GenerateContentResponse response =
+ client.models.generateContent(modelName, contents, config);
+ ChatMessage result = convertResponse(response);
+
+ recordUsage(result, modelName, response);
+
+ return result;
+ } catch (IllegalArgumentException e) {
+ // Preserve the validation-error contract: surface IAE unwrapped,
consistent with the
+ // constructor.
+ throw e;
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to call Gemini generateContent
API.", e);
+ }
+ }
+
+ // Package-visible for testing. Walks ASSISTANT messages and records every
tool-call's
+ // `original_id` (or `id`) -> function `name` mapping so TOOL turns can
resolve their name from
+ // `externalId` alone (which is what the runtime supplies).
+ static Map<String, String> buildToolCallIdToNameMap(List<ChatMessage>
messages) {
+ Map<String, String> map = new HashMap<>();
+ for (ChatMessage message : messages) {
+ if (message.getRole() != MessageRole.ASSISTANT) {
+ continue;
+ }
+ List<Map<String, Object>> toolCalls = message.getToolCalls();
+ if (toolCalls == null) {
+ continue;
+ }
+ for (Map<String, Object> call : toolCalls) {
+ Object id = call.get("original_id");
+ if (id == null) {
+ id = call.get("id");
+ }
+ Object function = call.get("function");
+ if (id == null || !(function instanceof Map)) {
+ continue;
+ }
+ Object name = ((Map<?, ?>) function).get("name");
+ if (name != null) {
+ map.put(id.toString(), name.toString());
+ }
+ }
+ }
+ return map;
+ }
+
+ private GenerateContentConfig buildConfig(
+ List<ChatMessage> messages,
+ List<org.apache.flink.agents.api.tools.Tool> tools,
+ Map<String, Object> arguments) {
+ GenerateContentConfig.Builder builder =
GenerateContentConfig.builder();
+
+ Content systemInstruction = extractSystemInstruction(messages);
+ if (systemInstruction != null) {
+ builder.systemInstruction(systemInstruction);
+ }
+
+ Object temperature = arguments.remove("temperature");
+ if (temperature instanceof Number) {
+ builder.temperature(((Number) temperature).floatValue());
+ }
+
+ Object maxOutputTokens = arguments.remove("max_output_tokens");
+ if (maxOutputTokens instanceof Number) {
+ builder.maxOutputTokens(((Number) maxOutputTokens).intValue());
+ }
+
+ @SuppressWarnings("unchecked")
+ Map<String, Object> additionalKwargs =
+ (Map<String, Object>) arguments.remove("additional_kwargs");
+ if (additionalKwargs != null) {
+ applyAdditionalKwargs(builder, additionalKwargs);
+ }
+
+ if (tools != null && !tools.isEmpty()) {
+ builder.tools(List.of(convertTools(tools)));
+ }
+
+ return builder.build();
+ }
+
+ // Package-visible for unit testing of the additional-kwargs forwarding.
+ void applyAdditionalKwargs(GenerateContentConfig.Builder builder,
Map<String, Object> kwargs) {
+ for (Map.Entry<String, Object> entry : kwargs.entrySet()) {
+ String key = entry.getKey();
+ Object value = entry.getValue();
+ if (value == null) {
+ continue;
+ }
+ switch (key) {
+ case "top_k":
+ // Gemini's protocol defines topK as a float, despite the
OpenAI/Anthropic
+ // convention of an integer.
+ if (value instanceof Number) {
+ builder.topK(((Number) value).floatValue());
+ }
+ break;
+ case "top_p":
+ if (value instanceof Number) {
+ builder.topP(((Number) value).floatValue());
+ }
+ break;
+ case "stop_sequences":
+ if (value instanceof List) {
+ List<String> stopSequences =
+ ((List<?>) value)
+ .stream()
+ .filter(Objects::nonNull)
+ .map(Object::toString)
+ .collect(Collectors.toList());
+ builder.stopSequences(stopSequences);
+ }
+ break;
+ default:
+ // The Gemini SDK's GenerateContentConfig.Builder is
AutoValue-generated and
+ // does not accept arbitrary body fields (unlike
Anthropic/OpenAI which expose
+ // putAdditionalBodyProperty). Surface a warning so the
user can see which key
+ // was dropped instead of silently mis-configuring
sampling.
+ LOG.warn(
+ "Ignoring additional_kwargs.{}: not recognized by
the Gemini connector"
+ + " (supported keys: top_k, top_p,
stop_sequences).",
+ key);
+ break;
+ }
+ }
+ }
+
+ private Tool convertTools(List<org.apache.flink.agents.api.tools.Tool>
tools) {
+ List<FunctionDeclaration> declarations = new ArrayList<>(tools.size());
+ for (org.apache.flink.agents.api.tools.Tool tool : tools) {
+ ToolMetadata metadata = tool.getMetadata();
+ FunctionDeclaration.Builder builder =
+ FunctionDeclaration.builder()
+ .name(metadata.getName())
+ .description(metadata.getDescription());
+
+ String schema = metadata.getInputSchema();
+ if (schema != null && !schema.isBlank()) {
+ builder.parametersJsonSchema(parseSchema(schema));
+ }
+
+ declarations.add(builder.build());
+ }
+ return Tool.builder().functionDeclarations(declarations).build();
+ }
+
+ private Content extractSystemInstruction(List<ChatMessage> messages) {
+ Part[] parts =
+ messages.stream()
+ .filter(m -> m.getRole() == MessageRole.SYSTEM)
+ .map(m ->
Part.fromText(Optional.ofNullable(m.getContent()).orElse("")))
+ .toArray(Part[]::new);
+ return parts.length == 0 ? null : Content.fromParts(parts);
+ }
+
+ // Package-visible for unit testing of the message conversion.
+ Content convertToContent(ChatMessage message, Map<String, String>
toolCallIdToName) {
+ MessageRole role = message.getRole();
+ String content = Optional.ofNullable(message.getContent()).orElse("");
+
+ switch (role) {
+ case USER:
+ return Content.builder()
+ .role("user")
+ .parts(List.of(Part.fromText(content)))
+ .build();
+
+ case ASSISTANT:
+ List<Part> parts = new ArrayList<>();
+ if (!content.isEmpty()) {
+ parts.add(Part.fromText(content));
+ }
+ List<Map<String, Object>> toolCalls = message.getToolCalls();
+ if (toolCalls != null) {
+ for (Map<String, Object> call : toolCalls) {
+ parts.add(convertToolCallToPart(call));
+ }
+ }
+ if (parts.isEmpty()) {
+ parts.add(Part.fromText(""));
+ }
+ return Content.builder().role("model").parts(parts).build();
+
+ case TOOL:
+ String functionName = resolveToolFunctionName(message,
toolCallIdToName);
+ Map<String, Object> responseMap = new LinkedHashMap<>();
+ responseMap.put("result", content);
+ return Content.builder()
+ .role("user")
+ .parts(List.of(Part.fromFunctionResponse(functionName,
responseMap)))
+ .build();
+
+ default:
+ throw new IllegalArgumentException("Unsupported role: " +
role);
+ }
+ }
+
+ private static String resolveToolFunctionName(
+ ChatMessage toolMessage, Map<String, String> toolCallIdToName) {
+ // 1. Honor an explicit `name` if the caller supplied one.
+ Object explicit = toolMessage.getExtraArgs().get("name");
+ if (explicit != null) {
+ return explicit.toString();
+ }
+ // 2. Otherwise look up the function name via the tool-call id the
runtime supplies as
+ // `externalId` (set equal to the assistant turn's `original_id` by
ToolCallAction).
+ Object externalId = toolMessage.getExtraArgs().get("externalId");
+ if (externalId != null && toolCallIdToName != null) {
+ String name = toolCallIdToName.get(externalId.toString());
+ if (name != null) {
+ return name;
+ }
+ }
+ throw new IllegalArgumentException(
+ "Tool message must carry the function name: provide either
'name' in extraArgs, or"
+ + " an 'externalId' matching a prior ASSISTANT
tool-call's id.");
+ }
+
+ // Package-visible for unit testing of the tool-call round-trip.
+ Part convertToolCallToPart(Map<String, Object> call) {
+ Map<String, Object> functionPayload = toMap(call.get("function"));
+ String functionName = String.valueOf(functionPayload.get("name"));
+ Map<String, Object> argsMap = toMap(functionPayload.get("arguments"));
+
+ FunctionCall.Builder fcBuilder =
FunctionCall.builder().name(functionName).args(argsMap);
+ Object originalId = call.get("original_id");
+ if (originalId != null) {
+ fcBuilder.id(originalId.toString());
+ }
+
+ Part.Builder partBuilder =
Part.builder().functionCall(fcBuilder.build());
+ // Echo back the thoughtSignature captured from the model response
(Gemini 3 requirement).
+ Object signature = call.get("thought_signature");
+ if (signature != null) {
+
partBuilder.thoughtSignature(Base64.getDecoder().decode(signature.toString()));
+ }
+ return partBuilder.build();
+ }
+
+ private Object parseSchema(String schemaJson) {
+ try {
+ return mapper.readValue(schemaJson, MAP_TYPE);
+ } catch (JsonProcessingException e) {
+ throw new RuntimeException("Failed to parse tool schema JSON.", e);
+ }
+ }
+
+ private ChatMessage convertResponse(GenerateContentResponse response) {
+ // Walk the first candidate's parts directly (rather than the
response.text()/
+ // functionCalls() conveniences) so we can capture the part-level
thoughtSignature that
+ // Gemini 3 emits alongside each functionCall and requires to be
echoed back on the next
+ // turn.
+ StringBuilder textContent = new StringBuilder();
+ List<Map<String, Object>> toolCalls = new ArrayList<>();
+
+ List<Candidate> candidates = response.candidates().orElseGet(List::of);
+ if (candidates.isEmpty()) {
+ throw new IllegalStateException(
+ "Gemini response did not contain any candidates (likely
safety-blocked or"
+ + " filtered).");
+ }
+ // Let the SDK validate the finish reason: this raises
IllegalArgumentException when the
+ // model finished for an unexpected reason (SAFETY, MAX_TOKENS,
RECITATION, …) instead of
+ // silently returning a truncated or filtered message. The IAE is
propagated unwrapped by
+ // chat()'s catch block, matching the constructor's error contract.
+ response.checkFinishReason();
+
+ List<Part> parts =
candidates.get(0).content().flatMap(Content::parts).orElseGet(List::of);
+
+ for (Part part : parts) {
+ part.text().ifPresent(textContent::append);
+ part.functionCall()
+ .ifPresent(
+ fc ->
+ toolCalls.add(
+ convertFunctionCall(
+ fc,
part.thoughtSignature().orElse(null))));
+ }
+
+ ChatMessage chatMessage =
ChatMessage.assistant(textContent.toString());
+ if (!toolCalls.isEmpty()) {
+ chatMessage.setToolCalls(toolCalls);
+ }
+ return chatMessage;
+ }
+
+ // Package-visible for unit testing of the function-call parsing.
+ Map<String, Object> convertFunctionCall(FunctionCall functionCall, byte[]
thoughtSignature) {
+ String id = functionCall.id().orElse(null);
+ String name = functionCall.name().orElse("");
+ Map<String, Object> argsMap =
functionCall.args().orElseGet(LinkedHashMap::new);
+
+ Map<String, Object> functionMap = new LinkedHashMap<>();
+ functionMap.put("name", name);
+ functionMap.put("arguments", argsMap);
+
+ Map<String, Object> toolCall = new LinkedHashMap<>();
+ if (id != null) {
+ toolCall.put("id", id);
+ toolCall.put("original_id", id);
+ }
+ toolCall.put("type", "function");
+ toolCall.put("function", functionMap);
+ // Gemini 3 requires the opaque thoughtSignature to be echoed back
when the tool-call turn
+ // is replayed. Stash it as Base64 so it survives the Map<String,
Object> representation.
+ if (thoughtSignature != null) {
+ toolCall.put("thought_signature",
Base64.getEncoder().encodeToString(thoughtSignature));
+ }
+ return toolCall;
+ }
+
+ private void recordUsage(
+ ChatMessage result, String modelName, GenerateContentResponse
response) {
+ GenerateContentResponseUsageMetadata usage =
response.usageMetadata().orElse(null);
+ if (usage == null) {
+ return;
+ }
+ long promptTokens = usage.promptTokenCount().orElse(0);
+ long completionTokens = usage.candidatesTokenCount().orElse(0);
+ result.getExtraArgs().put("model_name", modelName);
+ result.getExtraArgs().put("promptTokens", promptTokens);
+ result.getExtraArgs().put("completionTokens", completionTokens);
+ }
+
+ private Map<String, Object> toMap(Object value) {
+ if (value instanceof Map) {
+ @SuppressWarnings("unchecked")
+ Map<String, Object> casted = (Map<String, Object>) value;
+ return new LinkedHashMap<>(casted);
+ }
+ if (value == null) {
+ return new LinkedHashMap<>();
+ }
+ return mapper.convertValue(value, MAP_TYPE);
+ }
+}
diff --git
a/integrations/chat-models/gemini/src/main/java/org/apache/flink/agents/integrations/chatmodels/gemini/GeminiChatModelSetup.java
b/integrations/chat-models/gemini/src/main/java/org/apache/flink/agents/integrations/chatmodels/gemini/GeminiChatModelSetup.java
new file mode 100644
index 00000000..0cb84394
--- /dev/null
+++
b/integrations/chat-models/gemini/src/main/java/org/apache/flink/agents/integrations/chatmodels/gemini/GeminiChatModelSetup.java
@@ -0,0 +1,150 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.flink.agents.integrations.chatmodels.gemini;
+
+import org.apache.flink.agents.api.chat.model.BaseChatModelSetup;
+import org.apache.flink.agents.api.resource.ResourceContext;
+import org.apache.flink.agents.api.resource.ResourceDescriptor;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * Chat model setup for the Google Gemini {@code generateContent} API.
+ *
+ * <p>Responsible for providing per-chat configuration such as model,
temperature, max output
+ * tokens, tool bindings, and additional Gemini parameters. The setup
delegates execution to {@link
+ * GeminiChatModelConnection}.
+ *
+ * <p>Supported parameters:
+ *
+ * <ul>
+ * <li><b>connection</b> (required): Name of the GeminiChatModelConnection
resource
+ * <li><b>model</b> (optional): Model name (default: gemini-3.1-pro-preview)
+ * <li><b>temperature</b> (optional): Sampling temperature 0.0-2.0 (default:
0.1)
+ * <li><b>max_output_tokens</b> (optional): Maximum tokens in response
(default: 1024)
+ * <li><b>tools</b> (optional): List of tool names available for the model
to use
+ * <li><b>additional_kwargs</b> (optional): Additional parameters (e.g.
top_k, top_p)
+ * </ul>
+ *
+ * <p>Example usage:
+ *
+ * <pre>{@code
+ * public class MyAgent extends Agent {
+ * @ChatModelSetup
+ * public static ResourceDesc gemini() {
+ * return
ResourceDescriptor.Builder.newBuilder(GeminiChatModelSetup.class.getName())
+ * .addInitialArgument("connection", "myGeminiConnection")
+ * .addInitialArgument("model", "gemini-3.1-pro-preview")
+ * .addInitialArgument("temperature", 0.3d)
+ * .addInitialArgument("max_output_tokens", 2048)
+ * .addInitialArgument("tools", List.of("getWeather"))
+ * .build();
+ * }
+ * }
+ * }</pre>
+ */
+public class GeminiChatModelSetup extends BaseChatModelSetup {
+
+ private static final String DEFAULT_MODEL = "gemini-3.1-pro-preview";
+ private static final double DEFAULT_TEMPERATURE = 0.1d;
+ private static final long DEFAULT_MAX_OUTPUT_TOKENS = 1024L;
+
+ private final Double temperature;
+ private final Long maxOutputTokens;
+ private final Map<String, Object> additionalArguments;
+
+ public GeminiChatModelSetup(ResourceDescriptor descriptor, ResourceContext
resourceContext) {
+ super(descriptor, resourceContext);
+ this.temperature =
+
Optional.ofNullable(descriptor.<Number>getArgument("temperature"))
+ .map(Number::doubleValue)
+ .orElse(DEFAULT_TEMPERATURE);
+ if (this.temperature < 0.0 || this.temperature > 2.0) {
+ throw new IllegalArgumentException("temperature must be between
0.0 and 2.0");
+ }
+
+ this.maxOutputTokens =
+
Optional.ofNullable(descriptor.<Number>getArgument("max_output_tokens"))
+ .map(Number::longValue)
+ .orElse(DEFAULT_MAX_OUTPUT_TOKENS);
+ if (this.maxOutputTokens <= 0) {
+ throw new IllegalArgumentException("max_output_tokens must be
greater than 0");
+ }
+
+ this.additionalArguments =
+ Optional.ofNullable(
+ descriptor.<Map<String,
Object>>getArgument("additional_kwargs"))
+ .map(HashMap::new)
+ .orElseGet(HashMap::new);
+
+ if (this.model == null || this.model.isBlank()) {
+ this.model = DEFAULT_MODEL;
+ }
+ }
+
+ public GeminiChatModelSetup(
+ String model,
+ double temperature,
+ long maxOutputTokens,
+ Map<String, Object> additionalArguments,
+ List<String> tools,
+ ResourceContext resourceContext) {
+ this(
+ createDescriptor(model, temperature, maxOutputTokens,
additionalArguments, tools),
+ resourceContext);
+ }
+
+ @Override
+ public Map<String, Object> getParameters() {
+ Map<String, Object> parameters = new HashMap<>();
+ if (model != null) {
+ parameters.put("model", model);
+ }
+ parameters.put("temperature", temperature);
+ parameters.put("max_output_tokens", maxOutputTokens);
+ if (additionalArguments != null && !additionalArguments.isEmpty()) {
+ parameters.put("additional_kwargs", additionalArguments);
+ }
+ return parameters;
+ }
+
+ private static ResourceDescriptor createDescriptor(
+ String model,
+ double temperature,
+ long maxOutputTokens,
+ Map<String, Object> additionalArguments,
+ List<String> tools) {
+ ResourceDescriptor.Builder builder =
+
ResourceDescriptor.Builder.newBuilder(GeminiChatModelSetup.class.getName())
+ .addInitialArgument("model", model)
+ .addInitialArgument("temperature", temperature)
+ .addInitialArgument("max_output_tokens",
maxOutputTokens);
+
+ if (additionalArguments != null && !additionalArguments.isEmpty()) {
+ builder.addInitialArgument("additional_kwargs",
additionalArguments);
+ }
+ if (tools != null && !tools.isEmpty()) {
+ builder.addInitialArgument("tools", tools);
+ }
+
+ return builder.build();
+ }
+}
diff --git
a/integrations/chat-models/gemini/src/test/java/org/apache/flink/agents/integrations/chatmodels/gemini/GeminiChatModelConnectionTest.java
b/integrations/chat-models/gemini/src/test/java/org/apache/flink/agents/integrations/chatmodels/gemini/GeminiChatModelConnectionTest.java
new file mode 100644
index 00000000..abc616f6
--- /dev/null
+++
b/integrations/chat-models/gemini/src/test/java/org/apache/flink/agents/integrations/chatmodels/gemini/GeminiChatModelConnectionTest.java
@@ -0,0 +1,346 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.agents.integrations.chatmodels.gemini;
+
+import com.google.genai.types.Content;
+import com.google.genai.types.FunctionCall;
+import com.google.genai.types.GenerateContentConfig;
+import com.google.genai.types.Part;
+import org.apache.flink.agents.api.chat.messages.ChatMessage;
+import org.apache.flink.agents.api.chat.messages.MessageRole;
+import org.apache.flink.agents.api.chat.model.BaseChatModelConnection;
+import org.apache.flink.agents.api.resource.ResourceContext;
+import org.apache.flink.agents.api.resource.ResourceDescriptor;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.Base64;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Unit tests for {@link GeminiChatModelConnection}. These exercise the
protocol-conversion logic
+ * with no network access, so they run in CI without any API key.
+ */
+class GeminiChatModelConnectionTest {
+
+ private static final ResourceContext NOOP =
ResourceContext.fromGetResource((a, b) -> null);
+
+ private static ResourceDescriptor descriptor(String apiKey, String
baseUrl, String model) {
+ ResourceDescriptor.Builder b =
+
ResourceDescriptor.Builder.newBuilder(GeminiChatModelConnection.class.getName());
+ if (apiKey != null) {
+ b.addInitialArgument("api_key", apiKey);
+ }
+ if (baseUrl != null) {
+ b.addInitialArgument("base_url", baseUrl);
+ }
+ if (model != null) {
+ b.addInitialArgument("model", model);
+ }
+ return b.build();
+ }
+
+ private static GeminiChatModelConnection connection() {
+ return new GeminiChatModelConnection(
+ descriptor("test-key", null, "gemini-3-pro-preview"), NOOP);
+ }
+
+ @Test
+ @DisplayName("Constructor with api_key creates a connection")
+ void testConstructorWithApiKey() {
+ GeminiChatModelConnection conn = connection();
+ assertThat(conn).isInstanceOf(BaseChatModelConnection.class);
+ }
+
+ @Test
+ @DisplayName("Constructor with base_url (proxy) creates a connection
without api_key")
+ void testConstructorWithBaseUrl() {
+ GeminiChatModelConnection conn =
+ new GeminiChatModelConnection(
+ descriptor(null, "http://127.0.0.1:15799",
"gemini-3-pro-preview"), NOOP);
+ assertThat(conn).isInstanceOf(BaseChatModelConnection.class);
+ }
+
+ @Test
+ @DisplayName("Constructor throws when neither api_key nor base_url is
provided")
+ void testConstructorThrowsWithoutCredentials() {
+ assertThatThrownBy(
+ () ->
+ new GeminiChatModelConnection(
+ descriptor(null, null,
"gemini-3-pro-preview"), NOOP))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("api_key or base_url");
+ }
+
+ @Test
+ @DisplayName(
+ "Vertex AI path is wired but not e2e-tested in CI. We only assert
here that "
+ + "vertex_ai=true does NOT silently fall through to the
Developer-API "
+ + "construction success path; either it succeeds with ADC,
or it surfaces a "
+ + "credentials / configuration error. A real Vertex run is
a follow-up.")
+ void testConstructorVertexAiIsWired() {
+ ResourceDescriptor desc =
+
ResourceDescriptor.Builder.newBuilder(GeminiChatModelConnection.class.getName())
+ .addInitialArgument("vertex_ai", true)
+ .addInitialArgument("project",
"test-project-does-not-exist")
+ .addInitialArgument("location", "us-central1")
+ .addInitialArgument("model", "gemini-3-pro-preview")
+ .build();
+ // Two acceptable outcomes:
+ // 1. CI/dev box without ADC -> the SDK throws while resolving
credentials.
+ // 2. A machine with ADC configured -> construction succeeds. We
close the client to
+ // release resources.
+ // What must NOT happen: vertex_ai is silently ignored and the
Developer-API path is taken,
+ // which would mean the Vertex flag is dead code.
+ try {
+ GeminiChatModelConnection conn = new
GeminiChatModelConnection(desc, NOOP);
+ // Reached only when ADC is configured locally. Smoke-checked the
build path.
+ assertThat(conn).isInstanceOf(BaseChatModelConnection.class);
+ conn.close();
+ } catch (RuntimeException e) {
+ // ADC missing: the SDK surfaces a credentials error. The exact
message is SDK-internal;
+ // the important assertion is that an error was raised, not silent
fallthrough.
+ assertThat(e).isNotNull();
+ }
+ }
+
+ @Test
+ @DisplayName("convertToContent maps USER role to a Gemini user turn")
+ void testConvertUserMessage() {
+ Content content =
+ connection().convertToContent(ChatMessage.user("hello"),
Collections.emptyMap());
+ assertThat(content.role()).hasValue("user");
+
assertThat(content.parts().orElseThrow().get(0).text()).hasValue("hello");
+ }
+
+ @Test
+ @DisplayName("convertToContent maps ASSISTANT role to a Gemini model turn")
+ void testConvertAssistantMessage() {
+ Content content =
+ connection()
+ .convertToContent(
+ ChatMessage.assistant("hi there"),
Collections.emptyMap());
+ assertThat(content.role()).hasValue("model");
+ assertThat(content.parts().orElseThrow().get(0).text()).hasValue("hi
there");
+ }
+
+ @Test
+ @DisplayName("convertToContent uses explicit `name` in extraArgs when
supplied")
+ void testConvertToolMessageWithExplicitName() {
+ ChatMessage tool = ChatMessage.tool("sunny, 22C");
+ tool.getExtraArgs().put("name", "get_weather");
+
+ Content content = connection().convertToContent(tool,
Collections.emptyMap());
+ assertThat(content.role()).hasValue("user");
+ Part part = content.parts().orElseThrow().get(0);
+ assertThat(part.functionResponse()).isPresent();
+
assertThat(part.functionResponse().orElseThrow().name()).hasValue("get_weather");
+ }
+
+ @Test
+ @DisplayName(
+ "convertToContent resolves the function name from `externalId`
when the runtime omits "
+ + "`name` (matches ChatModelAction's emission shape)")
+ void testRuntimeShapeToolMessageResolvesNameFromExternalId() {
+ // Runtime contract: ChatModelAction emits TOOL messages with only
`externalId` in
+ // extraArgs, matching how Anthropic/OpenAI siblings work. The name
must be recovered from
+ // the prior ASSISTANT turn's tool-call map.
+ ChatMessage tool = ChatMessage.tool("sunny, 22C");
+ tool.getExtraArgs().put("externalId", "call_abc");
+
+ Map<String, String> idToName = Map.of("call_abc", "get_weather");
+
+ Content content = connection().convertToContent(tool, idToName);
+ assertThat(content.role()).hasValue("user");
+ Part part = content.parts().orElseThrow().get(0);
+ assertThat(part.functionResponse()).isPresent();
+
assertThat(part.functionResponse().orElseThrow().name()).hasValue("get_weather");
+ }
+
+ @Test
+ @DisplayName(
+ "convertToContent throws only when the function name truly cannot
be resolved (no "
+ + "`name`, no matching `externalId`)")
+ void testConvertToolMessageThrowsWhenUnresolvable() {
+ ChatMessage tool = ChatMessage.tool("result");
+ tool.getExtraArgs().put("externalId", "call_unknown");
+
+ assertThatThrownBy(() -> connection().convertToContent(tool,
Collections.emptyMap()))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("function name");
+ }
+
+ @Test
+ @DisplayName("convertFunctionCall captures name, args, id and Base64
thoughtSignature")
+ void testConvertFunctionCall() {
+ FunctionCall fc =
+ FunctionCall.builder()
+ .id("call_1")
+ .name("get_weather")
+ .args(Map.of("city", "Tokyo"))
+ .build();
+ byte[] signature = new byte[] {1, 2, 3, 4};
+
+ Map<String, Object> toolCall = connection().convertFunctionCall(fc,
signature);
+
+ assertThat(toolCall).containsEntry("id",
"call_1").containsEntry("original_id", "call_1");
+ assertThat(toolCall).containsEntry("type", "function");
+ @SuppressWarnings("unchecked")
+ Map<String, Object> function = (Map<String, Object>)
toolCall.get("function");
+ assertThat(function).containsEntry("name", "get_weather");
+ assertThat(function.get("arguments")).isEqualTo(Map.of("city",
"Tokyo"));
+ assertThat(toolCall.get("thought_signature"))
+ .isEqualTo(Base64.getEncoder().encodeToString(signature));
+ }
+
+ @Test
+ @DisplayName("convertFunctionCall omits thought_signature when absent")
+ void testConvertFunctionCallNoSignature() {
+ FunctionCall fc =
FunctionCall.builder().name("noop").args(Map.of()).build();
+ Map<String, Object> toolCall = connection().convertFunctionCall(fc,
null);
+ assertThat(toolCall).doesNotContainKey("thought_signature");
+ }
+
+ @Test
+ @DisplayName("Tool-call round-trip preserves name, args and
thoughtSignature")
+ void testToolCallRoundTrip() {
+ byte[] signature = new byte[] {9, 8, 7};
+ FunctionCall fc =
+ FunctionCall.builder()
+ .id("c1")
+ .name("get_weather")
+ .args(Map.of("city", "Osaka"))
+ .build();
+
+ GeminiChatModelConnection conn = connection();
+ Map<String, Object> toolCall = conn.convertFunctionCall(fc, signature);
+ Part part = conn.convertToolCallToPart(toolCall);
+
+ assertThat(part.functionCall()).isPresent();
+ FunctionCall rebuilt = part.functionCall().orElseThrow();
+ assertThat(rebuilt.name()).hasValue("get_weather");
+ assertThat(rebuilt.args().orElseThrow()).containsEntry("city",
"Osaka");
+ assertThat(part.thoughtSignature()).isPresent();
+ assertThat(part.thoughtSignature().orElseThrow()).isEqualTo(signature);
+ }
+
+ @Test
+ @DisplayName("convertToContent embeds tool calls into the assistant model
turn")
+ void testAssistantWithToolCalls() {
+ FunctionCall fc =
+ FunctionCall.builder()
+ .id("c2")
+ .name("get_weather")
+ .args(Map.of("city", "Kyoto"))
+ .build();
+ Map<String, Object> toolCall = connection().convertFunctionCall(fc,
null);
+ ChatMessage assistant = ChatMessage.assistant("", List.of(toolCall));
+
+ Content content = connection().convertToContent(assistant,
Collections.emptyMap());
+ assertThat(content.role()).hasValue("model");
+ assertThat(content.parts().orElseThrow())
+ .anySatisfy(p -> assertThat(p.functionCall()).isPresent());
+ }
+
+ @Test
+ @DisplayName(
+ "buildToolCallIdToNameMap mirrors what ChatModelAction emits:
ASSISTANT turn carries "
+ + "tool-call map, follow-up TOOL turn carries only
externalId")
+ void testRuntimeShapeMultiTurn() {
+ // Step 1: simulate the assistant's tool-call turn produced by
convertFunctionCall.
+ FunctionCall fc =
+ FunctionCall.builder()
+ .id("call_xyz")
+ .name("get_weather")
+ .args(Map.of("city", "Tokyo"))
+ .build();
+ Map<String, Object> toolCall = connection().convertFunctionCall(fc,
null);
+ ChatMessage assistantTurn = ChatMessage.assistant("",
List.of(toolCall));
+
+ // Step 2: the runtime emits a TOOL message with only externalId (no
name).
+ Map<String, Object> toolExtras = new HashMap<>();
+ toolExtras.put("externalId", "call_xyz");
+ ChatMessage toolTurn = new ChatMessage(MessageRole.TOOL, "sunny, 22C",
toolExtras);
+
+ List<ChatMessage> conversation =
+ List.of(ChatMessage.user("weather in Tokyo?"), assistantTurn,
toolTurn);
+
+ Map<String, String> idToName =
+
GeminiChatModelConnection.buildToolCallIdToNameMap(conversation);
+ assertThat(idToName).containsEntry("call_xyz", "get_weather");
+
+ // Round-trip: TOOL message converts to a functionResponse with the
recovered name.
+ Content content = connection().convertToContent(toolTurn, idToName);
+
assertThat(content.parts().orElseThrow().get(0).functionResponse().orElseThrow().name())
+ .hasValue("get_weather");
+ }
+
+ @Test
+ @DisplayName(
+ "applyAdditionalKwargs forwards top_k, top_p and stop_sequences
onto the "
+ + "GenerateContentConfig (mirrors Anthropic's
`additional_kwargs` path)")
+ void testApplyAdditionalKwargs() {
+ GenerateContentConfig.Builder builder =
GenerateContentConfig.builder();
+ Map<String, Object> kwargs =
+ Map.of("top_k", 40, "top_p", 0.9, "stop_sequences",
List.of("END", "STOP"));
+
+ connection().applyAdditionalKwargs(builder, kwargs);
+
+ GenerateContentConfig config = builder.build();
+ assertThat(config.topK()).hasValue(40f);
+ assertThat(config.topP()).hasValue(0.9f);
+
assertThat(config.stopSequences().orElseThrow()).containsExactly("END", "STOP");
+ }
+
+ @Test
+ @DisplayName("applyAdditionalKwargs ignores unknown keys without throwing
(logs a warning)")
+ void testApplyAdditionalKwargsIgnoresUnknown() {
+ GenerateContentConfig.Builder builder =
GenerateContentConfig.builder();
+ connection().applyAdditionalKwargs(builder, Map.of("not_a_real_param",
"x"));
+ GenerateContentConfig config = builder.build();
+ assertThat(config).isNotNull();
+ // Unknown key must not leak into a known field.
+ assertThat(config.topK()).isEmpty();
+ assertThat(config.topP()).isEmpty();
+ }
+
+ @Test
+ @DisplayName(
+ "applyAdditionalKwargs ignores known keys with the wrong value
type without throwing "
+ + "(e.g. top_k as a String) — must not silently set a
wrong value either")
+ void testApplyAdditionalKwargsIgnoresTypeMismatch() {
+ GenerateContentConfig.Builder builder =
GenerateContentConfig.builder();
+ connection()
+ .applyAdditionalKwargs(
+ builder,
+ Map.of(
+ "top_k", "fast", // wrong type
+ "stop_sequences", "STOP" // wrong type (should
be List)
+ ));
+ GenerateContentConfig config = builder.build();
+ assertThat(config.topK()).isEmpty();
+ assertThat(config.stopSequences()).isEmpty();
+ }
+}
diff --git
a/integrations/chat-models/gemini/src/test/java/org/apache/flink/agents/integrations/chatmodels/gemini/GeminiChatModelSetupTest.java
b/integrations/chat-models/gemini/src/test/java/org/apache/flink/agents/integrations/chatmodels/gemini/GeminiChatModelSetupTest.java
new file mode 100644
index 00000000..3ef50ed4
--- /dev/null
+++
b/integrations/chat-models/gemini/src/test/java/org/apache/flink/agents/integrations/chatmodels/gemini/GeminiChatModelSetupTest.java
@@ -0,0 +1,99 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.agents.integrations.chatmodels.gemini;
+
+import org.apache.flink.agents.api.chat.model.BaseChatModelSetup;
+import org.apache.flink.agents.api.resource.ResourceContext;
+import org.apache.flink.agents.api.resource.ResourceDescriptor;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests for {@link GeminiChatModelSetup}. */
+class GeminiChatModelSetupTest {
+
+ private static final ResourceContext NOOP =
ResourceContext.fromGetResource((a, b) -> null);
+
+ private static ResourceDescriptor.Builder base() {
+ return
ResourceDescriptor.Builder.newBuilder(GeminiChatModelSetup.class.getName())
+ .addInitialArgument("connection", "conn");
+ }
+
+ @Test
+ @DisplayName("getParameters applies default model, temperature and max
output tokens")
+ void testGetParametersDefaults() {
+ GeminiChatModelSetup setup = new GeminiChatModelSetup(base().build(),
NOOP);
+
+ Map<String, Object> params = setup.getParameters();
+ assertThat(params).containsEntry("model", "gemini-3.1-pro-preview");
+ assertThat(params).containsEntry("temperature", 0.1);
+ assertThat(params).containsEntry("max_output_tokens", 1024L);
+ }
+
+ @Test
+ @DisplayName("getParameters honors custom model, temperature and max
output tokens")
+ void testGetParametersCustom() {
+ ResourceDescriptor desc =
+ base().addInitialArgument("model", "gemini-3-flash-preview")
+ .addInitialArgument("temperature", 0.7)
+ .addInitialArgument("max_output_tokens", 4096)
+ .build();
+ GeminiChatModelSetup setup = new GeminiChatModelSetup(desc, NOOP);
+
+ Map<String, Object> params = setup.getParameters();
+ assertThat(params).containsEntry("model", "gemini-3-flash-preview");
+ assertThat(params).containsEntry("temperature", 0.7);
+ assertThat(params).containsEntry("max_output_tokens", 4096L);
+ }
+
+ @Test
+ @DisplayName("Constructor rejects out-of-range temperature")
+ void testInvalidTemperature() {
+ assertThatThrownBy(
+ () ->
+ new GeminiChatModelSetup(
+
base().addInitialArgument("temperature", 2.5).build(),
+ NOOP))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("temperature");
+ }
+
+ @Test
+ @DisplayName("Constructor rejects non-positive max output tokens")
+ void testInvalidMaxOutputTokens() {
+ assertThatThrownBy(
+ () ->
+ new GeminiChatModelSetup(
+
base().addInitialArgument("max_output_tokens", 0).build(),
+ NOOP))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("max_output_tokens");
+ }
+
+ @Test
+ @DisplayName("Extends BaseChatModelSetup")
+ void testInheritance() {
+ assertThat(new GeminiChatModelSetup(base().build(), NOOP))
+ .isInstanceOf(BaseChatModelSetup.class);
+ }
+}
diff --git a/integrations/chat-models/pom.xml b/integrations/chat-models/pom.xml
index e5f4b9d4..ef806452 100644
--- a/integrations/chat-models/pom.xml
+++ b/integrations/chat-models/pom.xml
@@ -34,6 +34,7 @@ under the License.
<module>anthropic</module>
<module>azureai</module>
<module>bedrock</module>
+ <module>gemini</module>
<module>ollama</module>
<module>openai</module>
</modules>
diff --git a/integrations/pom.xml b/integrations/pom.xml
index 75404881..25b73bed 100644
--- a/integrations/pom.xml
+++ b/integrations/pom.xml
@@ -37,6 +37,7 @@ under the License.
<openai.version>4.8.0</openai.version>
<anthropic.version>2.11.1</anthropic.version>
<aws.sdk.version>2.32.16</aws.sdk.version>
+ <google.genai.version>1.56.0</google.genai.version>
</properties>
<modules>
diff --git a/python/flink_agents/api/resource.py
b/python/flink_agents/api/resource.py
index 17a4c96d..21d03342 100644
--- a/python/flink_agents/api/resource.py
+++ b/python/flink_agents/api/resource.py
@@ -280,6 +280,10 @@ class ResourceName:
BEDROCK_CONNECTION =
"org.apache.flink.agents.integrations.chatmodels.bedrock.BedrockChatModelConnection"
BEDROCK_SETUP =
"org.apache.flink.agents.integrations.chatmodels.bedrock.BedrockChatModelSetup"
+ # Gemini
+ GEMINI_CONNECTION =
"org.apache.flink.agents.integrations.chatmodels.gemini.GeminiChatModelConnection"
+ GEMINI_SETUP =
"org.apache.flink.agents.integrations.chatmodels.gemini.GeminiChatModelSetup"
+
# Ollama
OLLAMA_CONNECTION =
"org.apache.flink.agents.integrations.chatmodels.ollama.OllamaChatModelConnection"
OLLAMA_SETUP =
"org.apache.flink.agents.integrations.chatmodels.ollama.OllamaChatModelSetup"