This is an automated email from the ASF dual-hosted git repository.
jrmccluskey pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/beam.git
The following commit(s) were added to refs/heads/master by this push:
new 83fcb2f9cdf [Gemini] Add Java GeminiModelHandler class (#39245)
83fcb2f9cdf is described below
commit 83fcb2f9cdfa630f63743bde1c48c88c49812e59
Author: Jack McCluskey <[email protected]>
AuthorDate: Tue Jul 21 13:34:07 2026 -0400
[Gemini] Add Java GeminiModelHandler class (#39245)
* [Gemini] Add Java GeminiModelHandler class
* Remove model scratch files
* address review comments
* checkstyle issue
* unit test fix
* Fix weird coder UX
* fix altered base class signatures
---
sdks/java/ml/inference/gemini/build.gradle | 42 ++++++
.../ml/inference/gemini/GeminiImageResponse.java | 33 +++++
.../inference/gemini/GeminiInferenceFunctions.java | 67 +++++++++
.../ml/inference/gemini/GeminiModelHandler.java | 86 +++++++++++
.../ml/inference/gemini/GeminiModelParameters.java | 60 ++++++++
.../ml/inference/gemini/GeminiRequestFunction.java | 31 ++++
.../sdk/ml/inference/gemini/GeminiStringInput.java | 33 +++++
.../ml/inference/gemini/GeminiStringResponse.java | 33 +++++
.../beam/sdk/ml/inference/gemini/package-info.java | 20 +++
.../ml/inference/gemini/GeminiModelHandlerIT.java | 163 +++++++++++++++++++++
.../inference/gemini/GeminiModelHandlerTest.java | 133 +++++++++++++++++
.../ml/inference/remote/PredictionResultCoder.java | 74 ++++++++++
.../sdk/ml/inference/remote/RemoteInference.java | 34 ++++-
.../ml/inference/remote/RemoteInferenceTest.java | 4 +-
settings.gradle.kts | 1 +
15 files changed, 809 insertions(+), 5 deletions(-)
diff --git a/sdks/java/ml/inference/gemini/build.gradle
b/sdks/java/ml/inference/gemini/build.gradle
new file mode 100644
index 00000000000..f62984eba88
--- /dev/null
+++ b/sdks/java/ml/inference/gemini/build.gradle
@@ -0,0 +1,42 @@
+/*
+ * 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.
+ */
+plugins {
+ id 'org.apache.beam.module'
+}
+
+applyJavaNature(
+ automaticModuleName: 'org.apache.beam.sdk.ml.inference.gemini',
+)
+provideIntegrationTestingDependencies()
+enableJavaPerformanceTesting()
+
+description = "Apache Beam :: SDKs :: Java :: ML :: Inference :: Gemini"
+ext.summary = "Gemini model handler for remote inference"
+
+dependencies {
+ implementation project(":sdks:java:ml:inference:remote")
+ implementation "com.google.genai:google-genai:1.59.0"
+
+
+ testRuntimeOnly project(path: ":runners:direct-java", configuration:
"shadow")
+ testImplementation project(path: ":sdks:java:core", configuration: "shadow")
+ testImplementation library.java.slf4j_api
+ testRuntimeOnly library.java.slf4j_simple
+ testImplementation library.java.junit
+ testImplementation library.java.mockito_core
+}
diff --git
a/sdks/java/ml/inference/gemini/src/main/java/org/apache/beam/sdk/ml/inference/gemini/GeminiImageResponse.java
b/sdks/java/ml/inference/gemini/src/main/java/org/apache/beam/sdk/ml/inference/gemini/GeminiImageResponse.java
new file mode 100644
index 00000000000..5d74c21347c
--- /dev/null
+++
b/sdks/java/ml/inference/gemini/src/main/java/org/apache/beam/sdk/ml/inference/gemini/GeminiImageResponse.java
@@ -0,0 +1,33 @@
+/*
+ * 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.beam.sdk.ml.inference.gemini;
+
+import org.apache.beam.sdk.ml.inference.remote.BaseResponse;
+
+/** Wrapper for image (byte array) responses from Gemini. */
+public class GeminiImageResponse implements BaseResponse {
+ public final byte[] imageBytes;
+
+ public GeminiImageResponse(byte[] imageBytes) {
+ this.imageBytes = imageBytes;
+ }
+
+ public byte[] getImageBytes() {
+ return imageBytes;
+ }
+}
diff --git
a/sdks/java/ml/inference/gemini/src/main/java/org/apache/beam/sdk/ml/inference/gemini/GeminiInferenceFunctions.java
b/sdks/java/ml/inference/gemini/src/main/java/org/apache/beam/sdk/ml/inference/gemini/GeminiInferenceFunctions.java
new file mode 100644
index 00000000000..5ba009b7194
--- /dev/null
+++
b/sdks/java/ml/inference/gemini/src/main/java/org/apache/beam/sdk/ml/inference/gemini/GeminiInferenceFunctions.java
@@ -0,0 +1,67 @@
+/*
+ * 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.beam.sdk.ml.inference.gemini;
+
+import com.google.genai.types.GenerateContentConfig;
+import com.google.genai.types.GenerateContentResponse;
+import com.google.genai.types.GenerateImagesConfig;
+import com.google.genai.types.GenerateImagesResponse;
+import java.util.ArrayList;
+import java.util.List;
+
+/** Common inference functions for Gemini. */
+public class GeminiInferenceFunctions {
+
+ /** Generates content from string prompts using the standard generateContent
API. */
+ public static GeminiRequestFunction<GeminiStringInput, GeminiStringResponse>
+ generateFromString() {
+ return (modelName, batch, client) -> {
+ List<GeminiStringResponse> results = new ArrayList<>();
+ for (GeminiStringInput input : batch) {
+ GenerateContentResponse response =
+ client.models.generateContent(
+ modelName, input.getText(),
GenerateContentConfig.builder().build());
+ String text = response.text();
+ results.add(new GeminiStringResponse(text != null ? text : ""));
+ }
+ return results;
+ };
+ }
+
+ /** Generates images from string prompts using the generateImages API. */
+ public static GeminiRequestFunction<GeminiStringInput, GeminiImageResponse>
+ generateImageFromString() {
+ return (modelName, batch, client) -> {
+ List<GeminiImageResponse> results = new ArrayList<>();
+ for (GeminiStringInput input : batch) {
+ GenerateImagesResponse response =
+ client.models.generateImages(
+ modelName, input.getText(),
GenerateImagesConfig.builder().build());
+ // Retrieve the base64 string or bytes from the first generated image
+ List<com.google.genai.types.Image> images = response.images();
+ if (images != null && !images.isEmpty()) {
+ byte[] imageBytes = images.get(0).imageBytes().orElse(new byte[0]);
+ results.add(new GeminiImageResponse(imageBytes));
+ } else {
+ results.add(new GeminiImageResponse(new byte[0]));
+ }
+ }
+ return results;
+ };
+ }
+}
diff --git
a/sdks/java/ml/inference/gemini/src/main/java/org/apache/beam/sdk/ml/inference/gemini/GeminiModelHandler.java
b/sdks/java/ml/inference/gemini/src/main/java/org/apache/beam/sdk/ml/inference/gemini/GeminiModelHandler.java
new file mode 100644
index 00000000000..ecdbf807816
--- /dev/null
+++
b/sdks/java/ml/inference/gemini/src/main/java/org/apache/beam/sdk/ml/inference/gemini/GeminiModelHandler.java
@@ -0,0 +1,86 @@
+/*
+ * 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.beam.sdk.ml.inference.gemini;
+
+import com.google.genai.Client;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.beam.sdk.ml.inference.remote.BaseInput;
+import org.apache.beam.sdk.ml.inference.remote.BaseModelHandler;
+import org.apache.beam.sdk.ml.inference.remote.BaseResponse;
+import org.apache.beam.sdk.ml.inference.remote.PredictionResult;
+
+/**
+ * Model handler for Google Gemini API inference requests.
+ *
+ * <p>This handler manages communication with Google's Gemini API, including
client initialization,
+ * request formatting, and response parsing. It allows executing a custom
{@link
+ * GeminiRequestFunction} against a batch of inputs.
+ */
+@SuppressWarnings("nullness")
+public class GeminiModelHandler<InputT extends BaseInput, OutputT extends
BaseResponse>
+ implements BaseModelHandler<GeminiModelParameters<InputT, OutputT>,
InputT, OutputT> {
+
+ private transient Client client;
+ private GeminiModelParameters<InputT, OutputT> modelParameters;
+
+ @Override
+ public void createClient(GeminiModelParameters<InputT, OutputT> parameters) {
+ if (parameters == null) {
+ throw new NullPointerException("GeminiModelParameters must not be null");
+ }
+ this.modelParameters = parameters;
+
+ // Configure client based on vertex or API key
+ if (parameters.getApiKey() != null) {
+ if (parameters.getProject() != null || parameters.getLocation() != null)
{
+ throw new IllegalArgumentException("Project and location must be null
if API key is set");
+ }
+ this.client = Client.builder().apiKey(parameters.getApiKey()).build();
+ } else {
+ Client.Builder builder = Client.builder();
+ if (parameters.getProject() != null && parameters.getLocation() != null)
{
+
builder.vertexAI(true).project(parameters.getProject()).location(parameters.getLocation());
+ } else if (parameters.getProject() != null || parameters.getLocation()
!= null) {
+ throw new IllegalArgumentException(
+ "Project and location must both be provided if one is provided");
+ }
+ this.client = builder.build();
+ }
+ }
+
+ @Override
+ public Iterable<PredictionResult<InputT, OutputT>> request(List<InputT>
input) {
+ try {
+ GeminiRequestFunction<InputT, OutputT> requestFn =
modelParameters.getRequestFn();
+ List<OutputT> responses =
requestFn.apply(modelParameters.getModelName(), input, client);
+
+ if (responses.size() != input.size()) {
+ throw new IllegalStateException("Number of responses must match number
of inputs");
+ }
+
+ List<PredictionResult<InputT, OutputT>> results = new ArrayList<>();
+ for (int i = 0; i < input.size(); i++) {
+ results.add(PredictionResult.create(input.get(i), responses.get(i)));
+ }
+ return results;
+ } catch (Exception e) {
+ throw new RuntimeException("Error during Gemini inference request", e);
+ }
+ }
+}
diff --git
a/sdks/java/ml/inference/gemini/src/main/java/org/apache/beam/sdk/ml/inference/gemini/GeminiModelParameters.java
b/sdks/java/ml/inference/gemini/src/main/java/org/apache/beam/sdk/ml/inference/gemini/GeminiModelParameters.java
new file mode 100644
index 00000000000..6ac4f578a24
--- /dev/null
+++
b/sdks/java/ml/inference/gemini/src/main/java/org/apache/beam/sdk/ml/inference/gemini/GeminiModelParameters.java
@@ -0,0 +1,60 @@
+/*
+ * 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.beam.sdk.ml.inference.gemini;
+
+import com.google.auto.value.AutoValue;
+import org.apache.beam.sdk.ml.inference.remote.BaseInput;
+import org.apache.beam.sdk.ml.inference.remote.BaseModelParameters;
+import org.apache.beam.sdk.ml.inference.remote.BaseResponse;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+@AutoValue
+public abstract class GeminiModelParameters<InputT extends BaseInput, OutputT
extends BaseResponse>
+ implements BaseModelParameters {
+
+ public abstract @Nullable String getApiKey();
+
+ public abstract @Nullable String getProject();
+
+ public abstract @Nullable String getLocation();
+
+ public abstract String getModelName();
+
+ public abstract GeminiRequestFunction<InputT, OutputT> getRequestFn();
+
+ public static <InputT extends BaseInput, OutputT extends BaseResponse>
+ Builder<InputT, OutputT> builder() {
+ return new AutoValue_GeminiModelParameters.Builder<InputT, OutputT>();
+ }
+
+ @AutoValue.Builder
+ public abstract static class Builder<InputT extends BaseInput, OutputT
extends BaseResponse> {
+ public abstract Builder<InputT, OutputT> setApiKey(String apiKey);
+
+ public abstract Builder<InputT, OutputT> setProject(String project);
+
+ public abstract Builder<InputT, OutputT> setLocation(String location);
+
+ public abstract Builder<InputT, OutputT> setModelName(String modelName);
+
+ public abstract Builder<InputT, OutputT> setRequestFn(
+ GeminiRequestFunction<InputT, OutputT> requestFn);
+
+ public abstract GeminiModelParameters<InputT, OutputT> build();
+ }
+}
diff --git
a/sdks/java/ml/inference/gemini/src/main/java/org/apache/beam/sdk/ml/inference/gemini/GeminiRequestFunction.java
b/sdks/java/ml/inference/gemini/src/main/java/org/apache/beam/sdk/ml/inference/gemini/GeminiRequestFunction.java
new file mode 100644
index 00000000000..15da8b360c9
--- /dev/null
+++
b/sdks/java/ml/inference/gemini/src/main/java/org/apache/beam/sdk/ml/inference/gemini/GeminiRequestFunction.java
@@ -0,0 +1,31 @@
+/*
+ * 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.beam.sdk.ml.inference.gemini;
+
+import com.google.genai.Client;
+import java.io.Serializable;
+import java.util.List;
+import org.apache.beam.sdk.ml.inference.remote.BaseInput;
+import org.apache.beam.sdk.ml.inference.remote.BaseResponse;
+
+/** Functional interface for custom request functions to the Gemini API. */
+@FunctionalInterface
+public interface GeminiRequestFunction<InputT extends BaseInput, OutputT
extends BaseResponse>
+ extends Serializable {
+ List<OutputT> apply(String modelName, List<InputT> batch, Client client)
throws Exception;
+}
diff --git
a/sdks/java/ml/inference/gemini/src/main/java/org/apache/beam/sdk/ml/inference/gemini/GeminiStringInput.java
b/sdks/java/ml/inference/gemini/src/main/java/org/apache/beam/sdk/ml/inference/gemini/GeminiStringInput.java
new file mode 100644
index 00000000000..8b0cc7c2689
--- /dev/null
+++
b/sdks/java/ml/inference/gemini/src/main/java/org/apache/beam/sdk/ml/inference/gemini/GeminiStringInput.java
@@ -0,0 +1,33 @@
+/*
+ * 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.beam.sdk.ml.inference.gemini;
+
+import org.apache.beam.sdk.ml.inference.remote.BaseInput;
+
+/** Wrapper for string inputs to Gemini. */
+public class GeminiStringInput implements BaseInput {
+ public final String text;
+
+ public GeminiStringInput(String text) {
+ this.text = text;
+ }
+
+ public String getText() {
+ return text;
+ }
+}
diff --git
a/sdks/java/ml/inference/gemini/src/main/java/org/apache/beam/sdk/ml/inference/gemini/GeminiStringResponse.java
b/sdks/java/ml/inference/gemini/src/main/java/org/apache/beam/sdk/ml/inference/gemini/GeminiStringResponse.java
new file mode 100644
index 00000000000..94bb4d7168d
--- /dev/null
+++
b/sdks/java/ml/inference/gemini/src/main/java/org/apache/beam/sdk/ml/inference/gemini/GeminiStringResponse.java
@@ -0,0 +1,33 @@
+/*
+ * 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.beam.sdk.ml.inference.gemini;
+
+import org.apache.beam.sdk.ml.inference.remote.BaseResponse;
+
+/** Wrapper for string responses from Gemini. */
+public class GeminiStringResponse implements BaseResponse {
+ public final String text;
+
+ public GeminiStringResponse(String text) {
+ this.text = text;
+ }
+
+ public String getText() {
+ return text;
+ }
+}
diff --git
a/sdks/java/ml/inference/gemini/src/main/java/org/apache/beam/sdk/ml/inference/gemini/package-info.java
b/sdks/java/ml/inference/gemini/src/main/java/org/apache/beam/sdk/ml/inference/gemini/package-info.java
new file mode 100644
index 00000000000..9dee9da9cc7
--- /dev/null
+++
b/sdks/java/ml/inference/gemini/src/main/java/org/apache/beam/sdk/ml/inference/gemini/package-info.java
@@ -0,0 +1,20 @@
+/*
+ * 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.
+ */
+
+/** Gemini model handler for remote inference. */
+package org.apache.beam.sdk.ml.inference.gemini;
diff --git
a/sdks/java/ml/inference/gemini/src/test/java/org/apache/beam/sdk/ml/inference/gemini/GeminiModelHandlerIT.java
b/sdks/java/ml/inference/gemini/src/test/java/org/apache/beam/sdk/ml/inference/gemini/GeminiModelHandlerIT.java
new file mode 100644
index 00000000000..c1188ad6d04
--- /dev/null
+++
b/sdks/java/ml/inference/gemini/src/test/java/org/apache/beam/sdk/ml/inference/gemini/GeminiModelHandlerIT.java
@@ -0,0 +1,163 @@
+/*
+ * 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.beam.sdk.ml.inference.gemini;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assume.assumeNotNull;
+import static org.junit.Assume.assumeTrue;
+
+import org.apache.beam.sdk.ml.inference.remote.PredictionResult;
+import org.apache.beam.sdk.ml.inference.remote.RemoteInference;
+import org.apache.beam.sdk.testing.PAssert;
+import org.apache.beam.sdk.testing.TestPipeline;
+import org.apache.beam.sdk.transforms.Create;
+import org.apache.beam.sdk.values.PCollection;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+
+/**
+ * Execute Gemini model handler integration test.
+ *
+ * <pre>
+ * ./gradlew :sdks:java:ml:inference:gemini:integrationTest \
+ * --tests org.apache.beam.sdk.ml.inference.gemini.GeminiModelHandlerIT \
+ * --info
+ * </pre>
+ */
+public class GeminiModelHandlerIT {
+
+ public static class GeminiStringContentHandler
+ extends GeminiModelHandler<GeminiStringInput, GeminiStringResponse> {}
+
+ public static class GeminiStringImageHandler
+ extends GeminiModelHandler<GeminiStringInput, GeminiImageResponse> {}
+
+ @Rule public final transient TestPipeline pipeline = TestPipeline.create();
+
+ private String apiKey;
+ private static final String API_KEY_ENV = "GEMINI_API_KEY";
+ private static final String DEFAULT_MODEL = "gemini-3.5-flash";
+
+ @Before
+ public void setUp() {
+ // Get API key
+ apiKey = System.getenv(API_KEY_ENV);
+
+ // Skip tests if API key is not provided
+ assumeNotNull(
+ "Gemini API key not found. Set "
+ + API_KEY_ENV
+ + " environment variable to run integration tests.",
+ apiKey);
+ assumeTrue(
+ "Gemini API key is empty. Set "
+ + API_KEY_ENV
+ + " environment variable to run integration tests.",
+ !apiKey.trim().isEmpty());
+ }
+
+ @Test
+ public void testSentimentAnalysisWithSingleInput() {
+ String input =
+ "Classify the sentiment of the following text as either positive or
negative: This product is absolutely amazing! I love it!";
+
+ PCollection<GeminiStringInput> inputs =
+ pipeline.apply("CreateSingleInput", Create.of(new
GeminiStringInput(input)));
+
+ PCollection<Iterable<PredictionResult<GeminiStringInput,
GeminiStringResponse>>> results =
+ inputs.apply(
+ "SentimentInference",
+ RemoteInference.<GeminiStringInput, GeminiStringResponse>invoke()
+ .handler(GeminiStringContentHandler.class)
+ .withParameters(
+ GeminiModelParameters.<GeminiStringInput,
GeminiStringResponse>builder()
+ .setApiKey(apiKey)
+ .setModelName(DEFAULT_MODEL)
+
.setRequestFn(GeminiInferenceFunctions.generateFromString())
+ .build()));
+
+ // Verify results
+ PAssert.that(results)
+ .satisfies(
+ batches -> {
+ int count = 0;
+ for (Iterable<PredictionResult<GeminiStringInput,
GeminiStringResponse>> batch :
+ batches) {
+ for (PredictionResult<GeminiStringInput, GeminiStringResponse>
result : batch) {
+ count++;
+ assertNotNull("Input should not be null", result.getInput());
+ assertNotNull("Output should not be null",
result.getOutput());
+
+ String sentiment =
result.getOutput().getText().toLowerCase();
+ assertTrue(
+ "Sentiment should be positive or negative, got: " +
sentiment,
+ sentiment.contains("positive") ||
sentiment.contains("negative"));
+ }
+ }
+ assertEquals("Should have exactly 1 result", 1, count);
+ return null;
+ });
+
+ pipeline.run().waitUntilFinish();
+ }
+
+ @Test
+ public void testGenerateImageFromString() {
+ String input = "A beautiful sunset over the mountains, digital art style.";
+
+ PCollection<GeminiStringInput> inputs =
+ pipeline.apply("CreateImageInput", Create.of(new
GeminiStringInput(input)));
+
+ PCollection<Iterable<PredictionResult<GeminiStringInput,
GeminiImageResponse>>> results =
+ inputs.apply(
+ "ImageInference",
+ RemoteInference.<GeminiStringInput, GeminiImageResponse>invoke()
+ .handler(GeminiStringImageHandler.class)
+ .withParameters(
+ GeminiModelParameters.<GeminiStringInput,
GeminiImageResponse>builder()
+ .setApiKey(apiKey)
+ .setModelName("imagen-4.0-generate-001")
+
.setRequestFn(GeminiInferenceFunctions.generateImageFromString())
+ .build()));
+
+ // Verify results
+ PAssert.that(results)
+ .satisfies(
+ batches -> {
+ int count = 0;
+ for (Iterable<PredictionResult<GeminiStringInput,
GeminiImageResponse>> batch :
+ batches) {
+ for (PredictionResult<GeminiStringInput, GeminiImageResponse>
result : batch) {
+ count++;
+ assertNotNull("Input should not be null", result.getInput());
+ assertNotNull("Output should not be null",
result.getOutput());
+ assertTrue(
+ "Output should have generated images",
+ result.getOutput().getImageBytes().length > 0);
+ }
+ }
+ assertEquals("Should have exactly 1 result", 1, count);
+ return null;
+ });
+
+ pipeline.run().waitUntilFinish();
+ }
+}
diff --git
a/sdks/java/ml/inference/gemini/src/test/java/org/apache/beam/sdk/ml/inference/gemini/GeminiModelHandlerTest.java
b/sdks/java/ml/inference/gemini/src/test/java/org/apache/beam/sdk/ml/inference/gemini/GeminiModelHandlerTest.java
new file mode 100644
index 00000000000..125f1e9c3e9
--- /dev/null
+++
b/sdks/java/ml/inference/gemini/src/test/java/org/apache/beam/sdk/ml/inference/gemini/GeminiModelHandlerTest.java
@@ -0,0 +1,133 @@
+/*
+ * 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.beam.sdk.ml.inference.gemini;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThrows;
+
+import java.util.Arrays;
+import java.util.List;
+import org.apache.beam.sdk.ml.inference.remote.PredictionResult;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class GeminiModelHandlerTest {
+
+ @Test
+ public void testAllParamsSet() {
+ GeminiModelParameters<GeminiStringInput, GeminiStringResponse> parameters =
+ GeminiModelParameters.<GeminiStringInput,
GeminiStringResponse>builder()
+ .setApiKey("test-key")
+ .setProject("test-project")
+ .setLocation("us-central1")
+ .setModelName("gemini-model-123")
+ .setRequestFn(GeminiInferenceFunctions.generateFromString())
+ .build();
+ GeminiModelHandler<GeminiStringInput, GeminiStringResponse> handler =
+ new GeminiModelHandler<>();
+ assertThrows(IllegalArgumentException.class, () ->
handler.createClient(parameters));
+ }
+
+ @Test
+ public void testMissingVertexLocationParam() {
+ GeminiModelParameters<GeminiStringInput, GeminiStringResponse> parameters =
+ GeminiModelParameters.<GeminiStringInput,
GeminiStringResponse>builder()
+ .setProject("test-project")
+ .setModelName("gemini-model-123")
+ .setRequestFn(GeminiInferenceFunctions.generateFromString())
+ .build();
+ GeminiModelHandler<GeminiStringInput, GeminiStringResponse> handler =
+ new GeminiModelHandler<>();
+ assertThrows(IllegalArgumentException.class, () ->
handler.createClient(parameters));
+ }
+
+ @Test
+ public void testMissingVertexProjectParam() {
+ GeminiModelParameters<GeminiStringInput, GeminiStringResponse> parameters =
+ GeminiModelParameters.<GeminiStringInput,
GeminiStringResponse>builder()
+ .setLocation("us-central1")
+ .setModelName("gemini-model-123")
+ .setRequestFn(GeminiInferenceFunctions.generateFromString())
+ .build();
+ GeminiModelHandler<GeminiStringInput, GeminiStringResponse> handler =
+ new GeminiModelHandler<>();
+ assertThrows(IllegalArgumentException.class, () ->
handler.createClient(parameters));
+ }
+
+ @Test
+ public void testNullParameters() {
+ GeminiModelHandler<GeminiStringInput, GeminiStringResponse> handler =
+ new GeminiModelHandler<>();
+ assertThrows(NullPointerException.class, () -> handler.createClient(null));
+ }
+
+ @Test
+ public void testRequest() throws Exception {
+ GeminiModelParameters<GeminiStringInput, GeminiStringResponse> parameters =
+ GeminiModelParameters.<GeminiStringInput,
GeminiStringResponse>builder()
+ .setApiKey("test-key")
+ .setModelName("gemini-model-123")
+ .setRequestFn(
+ (modelName, batch, client) ->
+ Arrays.asList(
+ new GeminiStringResponse("response1"),
+ new GeminiStringResponse("response2")))
+ .build();
+ GeminiModelHandler<GeminiStringInput, GeminiStringResponse> handler =
+ new GeminiModelHandler<>();
+ handler.createClient(parameters);
+
+ List<GeminiStringInput> input =
+ Arrays.asList(new GeminiStringInput("input1"), new
GeminiStringInput("input2"));
+ Iterable<PredictionResult<GeminiStringInput, GeminiStringResponse>>
results =
+ handler.request(input);
+
+ int count = 0;
+ for (PredictionResult<GeminiStringInput, GeminiStringResponse> result :
results) {
+ if (count == 0) {
+ assertEquals("input1", result.getInput().getText());
+ assertEquals("response1", result.getOutput().getText());
+ } else {
+ assertEquals("input2", result.getInput().getText());
+ assertEquals("response2", result.getOutput().getText());
+ }
+ count++;
+ }
+ assertEquals(2, count);
+ }
+
+ @Test
+ public void testRequestMismatchedResponseSize() throws Exception {
+ GeminiModelParameters<GeminiStringInput, GeminiStringResponse> parameters =
+ GeminiModelParameters.<GeminiStringInput,
GeminiStringResponse>builder()
+ .setApiKey("test-key")
+ .setModelName("gemini-model-123")
+ .setRequestFn(
+ (modelName, batch, client) -> Arrays.asList(new
GeminiStringResponse("response1")))
+ .build();
+ GeminiModelHandler<GeminiStringInput, GeminiStringResponse> handler =
+ new GeminiModelHandler<>();
+ handler.createClient(parameters);
+
+ List<GeminiStringInput> input =
+ Arrays.asList(new GeminiStringInput("input1"), new
GeminiStringInput("input2"));
+ assertThrows(RuntimeException.class, () -> handler.request(input));
+ }
+}
diff --git
a/sdks/java/ml/inference/remote/src/main/java/org/apache/beam/sdk/ml/inference/remote/PredictionResultCoder.java
b/sdks/java/ml/inference/remote/src/main/java/org/apache/beam/sdk/ml/inference/remote/PredictionResultCoder.java
new file mode 100644
index 00000000000..0d43870b880
--- /dev/null
+++
b/sdks/java/ml/inference/remote/src/main/java/org/apache/beam/sdk/ml/inference/remote/PredictionResultCoder.java
@@ -0,0 +1,74 @@
+/*
+ * 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.beam.sdk.ml.inference.remote;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.Arrays;
+import java.util.List;
+import org.apache.beam.sdk.coders.Coder;
+import org.apache.beam.sdk.coders.CoderException;
+import org.apache.beam.sdk.coders.StructuredCoder;
+
+/** A {@link Coder} for {@link PredictionResult}. */
+public class PredictionResultCoder<InputT, OutputT>
+ extends StructuredCoder<PredictionResult<InputT, OutputT>> {
+
+ private final Coder<InputT> inputCoder;
+ private final Coder<OutputT> outputCoder;
+
+ private PredictionResultCoder(Coder<InputT> inputCoder, Coder<OutputT>
outputCoder) {
+ this.inputCoder = inputCoder;
+ this.outputCoder = outputCoder;
+ }
+
+ public static <InputT, OutputT> PredictionResultCoder<InputT, OutputT> of(
+ Coder<InputT> inputCoder, Coder<OutputT> outputCoder) {
+ return new PredictionResultCoder<>(inputCoder, outputCoder);
+ }
+
+ @Override
+ public void encode(PredictionResult<InputT, OutputT> value, OutputStream
outStream)
+ throws CoderException, IOException {
+ if (value == null) {
+ throw new CoderException("cannot encode a null PredictionResult");
+ }
+ inputCoder.encode(value.getInput(), outStream);
+ outputCoder.encode(value.getOutput(), outStream);
+ }
+
+ @Override
+ public PredictionResult<InputT, OutputT> decode(InputStream inStream)
+ throws CoderException, IOException {
+ InputT input = inputCoder.decode(inStream);
+ OutputT output = outputCoder.decode(inStream);
+ return PredictionResult.create(input, output);
+ }
+
+ @Override
+ public List<? extends Coder<?>> getCoderArguments() {
+ return Arrays.asList(inputCoder, outputCoder);
+ }
+
+ @Override
+ public void verifyDeterministic() throws NonDeterministicException {
+ verifyDeterministic(this, "Input coder must be deterministic", inputCoder);
+ verifyDeterministic(this, "Output coder must be deterministic",
outputCoder);
+ }
+}
diff --git
a/sdks/java/ml/inference/remote/src/main/java/org/apache/beam/sdk/ml/inference/remote/RemoteInference.java
b/sdks/java/ml/inference/remote/src/main/java/org/apache/beam/sdk/ml/inference/remote/RemoteInference.java
index c571911a484..a998f4d0f07 100644
---
a/sdks/java/ml/inference/remote/src/main/java/org/apache/beam/sdk/ml/inference/remote/RemoteInference.java
+++
b/sdks/java/ml/inference/remote/src/main/java/org/apache/beam/sdk/ml/inference/remote/RemoteInference.java
@@ -21,6 +21,7 @@ import static
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Pr
import com.google.auto.value.AutoValue;
import java.util.List;
+import org.apache.beam.sdk.coders.Coder;
import org.apache.beam.sdk.io.components.throttling.ReactiveThrottler;
import org.apache.beam.sdk.transforms.BatchElements;
import org.apache.beam.sdk.transforms.DoFn;
@@ -88,6 +89,8 @@ public class RemoteInference {
abstract @Nullable Double overloadRatio();
+ abstract @Nullable Coder<OutputT> outputCoder();
+
abstract Builder<InputT, OutputT> builder();
@AutoValue.Builder
@@ -108,6 +111,8 @@ public class RemoteInference {
abstract Builder<InputT, OutputT> setOverloadRatio(Double overloadRatio);
+ abstract Builder<InputT, OutputT> setOutputCoder(Coder<OutputT>
outputCoder);
+
abstract Invoke<InputT, OutputT> build();
}
@@ -163,6 +168,14 @@ public class RemoteInference {
return builder().setOverloadRatio(overloadRatio).build();
}
+ /**
+ * Configures the coder for the output of the model. If not provided, it
will fallback to using
+ * standard Java serialization for the output element.
+ */
+ public Invoke<InputT, OutputT> withOutputCoder(Coder<OutputT> outputCoder)
{
+ return builder().setOutputCoder(outputCoder).build();
+ }
+
@Override
public PCollection<Iterable<PredictionResult<InputT, OutputT>>> expand(
PCollection<InputT> input) {
@@ -177,9 +190,24 @@ public class RemoteInference {
batchedInput = input.apply("BatchElements",
BatchElements.withDefaults());
}
- return batchedInput
- // Pass the list to the inference function
- .apply("RemoteInference", ParDo.of(new RemoteInferenceFn<InputT,
OutputT>(this)));
+ PCollection<Iterable<PredictionResult<InputT, OutputT>>> result =
+ batchedInput
+ // Pass the list to the inference function
+ .apply("RemoteInference", ParDo.of(new RemoteInferenceFn<InputT,
OutputT>(this)));
+
+ Coder<OutputT> outCoder = outputCoder();
+ if (outCoder != null) {
+ result.setCoder(
+ org.apache.beam.sdk.coders.IterableCoder.of(
+ PredictionResultCoder.of(input.getCoder(), outCoder)));
+ } else {
+ result.setCoder(
+ (org.apache.beam.sdk.coders.Coder)
+ org.apache.beam.sdk.coders.IterableCoder.of(
+
org.apache.beam.sdk.coders.SerializableCoder.of(PredictionResult.class)));
+ }
+
+ return result;
}
/**
diff --git
a/sdks/java/ml/inference/remote/src/test/java/org/apache/beam/sdk/ml/inference/remote/RemoteInferenceTest.java
b/sdks/java/ml/inference/remote/src/test/java/org/apache/beam/sdk/ml/inference/remote/RemoteInferenceTest.java
index 64691aa1fed..5fe8ea1cce1 100644
---
a/sdks/java/ml/inference/remote/src/test/java/org/apache/beam/sdk/ml/inference/remote/RemoteInferenceTest.java
+++
b/sdks/java/ml/inference/remote/src/test/java/org/apache/beam/sdk/ml/inference/remote/RemoteInferenceTest.java
@@ -45,7 +45,7 @@ public class RemoteInferenceTest {
@Rule public final transient TestPipeline pipeline = TestPipeline.create();
// Test input class
- public static class TestInput implements BaseInput {
+ public static class TestInput implements BaseInput, java.io.Serializable {
private final String value;
private TestInput(String value) {
@@ -84,7 +84,7 @@ public class RemoteInferenceTest {
}
// Test output class
- public static class TestOutput implements BaseResponse {
+ public static class TestOutput implements BaseResponse, java.io.Serializable
{
private final String result;
private TestOutput(String result) {
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 44e93029b54..8a116eeb394 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -285,6 +285,7 @@ include(":sdks:java:maven-archetypes:gcp-bom-examples")
include(":sdks:java:maven-archetypes:starter")
include(":sdks:java:ml:inference:remote")
include(":sdks:java:ml:inference:openai")
+include(":sdks:java:ml:inference:gemini")
include(":sdks:java:testing:nexmark")
include(":sdks:java:testing:expansion-service")
include(":sdks:java:testing:jpms-tests")