From 8574fc5bb6ac7edae99306b06c0a610f7da60048 Mon Sep 17 00:00:00 2001 From: Carlos Sanchez Date: Tue, 12 May 2026 21:59:37 +0200 Subject: [PATCH 1/6] fix: upgrade Mockito and JaCoCo for Java 25 compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mockito 5.20.0 and JaCoCo 0.8.12 both bundle an ASM version that does not recognize Java 25 class file format (major version 69), causing IllegalArgumentException at test time. Upgrading to versions released after Java 25 GA resolves the issue. - mockito: 5.20.0 → 5.23.0 - jacoco-maven-plugin: 0.8.12 → 0.8.14 --- pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 73e0ccbaa..ff645bfbe 100644 --- a/pom.xml +++ b/pom.xml @@ -55,7 +55,7 @@ 1.44.0 4.33.5 5.11.4 - 5.20.0 + 5.23.0 1.6.0 2.20.2 5.3.2 @@ -454,7 +454,7 @@ org.jacoco jacoco-maven-plugin - 0.8.12 + 0.8.14 From c6debc3ddb4b1abf8c845f3c20b780079a956032 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 20:57:58 +0000 Subject: [PATCH 2/6] chore(deps): bump org.apache.httpcomponents.client5:httpclient5 Bumps the maven group with 1 update in the / directory: [org.apache.httpcomponents.client5:httpclient5](https://github.com/apache/httpcomponents-client). Updates `org.apache.httpcomponents.client5:httpclient5` from 5.6 to 5.6.1 - [Changelog](https://github.com/apache/httpcomponents-client/blob/rel/v5.6.1/RELEASE_NOTES.txt) - [Commits](https://github.com/apache/httpcomponents-client/compare/rel/v5.6...rel/v5.6.1) --- updated-dependencies: - dependency-name: org.apache.httpcomponents.client5:httpclient5 dependency-version: 5.6.1 dependency-type: direct:production dependency-group: maven ... Signed-off-by: dependabot[bot] --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index ff645bfbe..eb5acd441 100644 --- a/pom.xml +++ b/pom.xml @@ -73,7 +73,7 @@ 3.27.7 2.15.0 3.9.0 - 5.6 + 5.6.1 4.1.118.Final @{jacoco.agent.argLine} --add-opens=java.base/java.nio=ALL-UNNAMED --add-opens=java.base/sun.nio.ch=ALL-UNNAMED --add-opens=java.base/java.util.concurrent=ALL-UNNAMED --add-opens=java.base/java.text=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.lang.reflect=ALL-UNNAMED --add-opens=java.base/jdk.internal.misc=ALL-UNNAMED -Dio.netty.tryReflectionSetAccessible=true From 9529c1aeecb324e1c00c6bd105df2a0e9f67ed26 Mon Sep 17 00:00:00 2001 From: Google Team Member Date: Wed, 13 May 2026 16:05:45 -0700 Subject: [PATCH 3/6] feat: Add ChatCompletionsHTTPClient and support for non-streaming requests This is part of a larger chain of commits for adding chat completion API support to the Apigee model. The HTTP client wraps payload construction (delegating to ChatCompletionsRequest.fromLlmRequest) and response parsing (delegating to ChatCompletionsResponse.ChatCompletion / ChatCompletionChunkCollection) for both non-streaming and streaming Server-Sent Events responses. END_PUBLIC Key behaviors: - Tri-state call timeout policy: * httpOptions == null OR timeout() empty: applies a default 5-minute call timeout to prevent indefinite hangs in the common unconfigured case. * httpOptions.timeout() == 0: respected as the explicit caller opt-in to infinite hang for long-running streams or batch jobs. * httpOptions.timeout() > 0: applied directly as the call timeout. This default intentionally diverges from the GenAI HttpOptions convention (which treats unset as infinite) as a defensive measure since this client does not yet have HTTP retry support. - SSE prefix handling accepts both "data: foo" (with space) and "data:foo" (without space) per the SSE spec, matching providers that omit the trailing space. - A single malformed JSON chunk in a streaming response is logged and skipped rather than aborting the entire stream. IOException (connection-level) still propagates as a stream error. - Content-Type is defensively forced to application/json by replacing rather than appending, preventing duplicate or conflicting headers if a caller supplies their own Content-Type. - Headers parameter accepts null (treated as no extra headers) and is stored as an ImmutableMap for thread-safe reuse across concurrent generateContent calls. Test additions (16 total, +12 new): - HTTP error status (4xx/5xx) propagation for both streaming and non-streaming. - Empty body propagation. - Streaming continues past a single malformed chunk. - SSE "data:" prefix accepted with or without trailing space. - Custom headers reach the wire. - Caller-supplied Content-Type is overridden, not appended. - baseUrl with and without trailing slash. - Constructor tri-state timeout (null, zero=infinite, positive). - Constructor null headers parameter. All testSubscriber.await() calls bounded to 500ms to prevent test hangs. PiperOrigin-RevId: 915109034 --- .../chat/ChatCompletionsHttpClient.java | 256 ++++++++++ .../chat/ChatCompletionsHttpClientTest.java | 477 ++++++++++++++++++ 2 files changed, 733 insertions(+) create mode 100644 core/src/main/java/com/google/adk/models/chat/ChatCompletionsHttpClient.java create mode 100644 core/src/test/java/com/google/adk/models/chat/ChatCompletionsHttpClientTest.java diff --git a/core/src/main/java/com/google/adk/models/chat/ChatCompletionsHttpClient.java b/core/src/main/java/com/google/adk/models/chat/ChatCompletionsHttpClient.java new file mode 100644 index 000000000..5b2b03a33 --- /dev/null +++ b/core/src/main/java/com/google/adk/models/chat/ChatCompletionsHttpClient.java @@ -0,0 +1,256 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed 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 + * + * https://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 com.google.adk.models.chat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.adk.JsonBaseModel; +import com.google.adk.models.LlmRequest; +import com.google.adk.models.LlmResponse; +import com.google.common.collect.ImmutableMap; +import com.google.genai.types.HttpOptions; +import io.reactivex.rxjava3.core.BackpressureStrategy; +import io.reactivex.rxjava3.core.Flowable; +import io.reactivex.rxjava3.core.FlowableEmitter; +import java.io.IOException; +import java.time.Duration; +import java.util.Map; +import java.util.Objects; +import okhttp3.Call; +import okhttp3.Callback; +import okhttp3.HttpUrl; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.ResponseBody; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * An HTTP client for interacting with OpenAI-compatible chat completions endpoints. + * + *

Supports both non-streaming responses (single {@link LlmResponse} emission) and streaming + * Server-Sent Events (SSE) responses (multiple incremental {@link LlmResponse} emissions). See the + * OpenAI Chat Completions API + * reference for the wire protocol. + */ +public class ChatCompletionsHttpClient { + private static final Logger logger = LoggerFactory.getLogger(ChatCompletionsHttpClient.class); + private static final ObjectMapper objectMapper = JsonBaseModel.getMapper(); + + private static final MediaType JSON = MediaType.get("application/json; charset=utf-8"); + + /** + * Default OkHttp call timeout used when the caller does not supply an {@link HttpOptions} + * timeout. Five minutes is long enough for most non-streaming completions and short enough to + * prevent indefinite hangs in the common case where the caller does not configure timeouts. + * Callers who need infinite (e.g. long batch jobs or open streams) can opt in by passing an + * {@link HttpOptions} with {@code timeout() == 0}. + */ + private static final Duration DEFAULT_CALL_TIMEOUT = Duration.ofMinutes(5); + + /** + * Shared OkHttpClient instance whose connection pool and thread dispatcher are reused across all + * {@link ChatCompletionsHttpClient} instances. Each instance forks this client via {@link + * OkHttpClient#newBuilder()} to apply per-instance timeouts without leaking pools. + */ + private static final OkHttpClient SHARED_POOL_CLIENT = new OkHttpClient(); + + private final OkHttpClient client; + private final HttpUrl completionsUrl; + private final ImmutableMap headers; + + /** + * Constructs a new {@link ChatCompletionsHttpClient} that facilitates API interaction with the + * standard {@code /chat/completions} REST endpoint. + * + *

All configuration is sourced from the supplied {@link HttpOptions}: + * + *

    + *
  • {@link HttpOptions#baseUrl()} -- required. The base URL of the chat completions + * endpoint. The {@code chat/completions} path segments are appended automatically using + * {@link HttpUrl}, which handles trailing slashes and percent-encoding deterministically. + * Set via {@code HttpOptions.builder().baseUrl("https://...").build()}. + *
  • {@link HttpOptions#headers()} -- optional. Extra HTTP headers to include in outgoing + * requests. The {@code Content-Type} header is set automatically and cannot be overridden. + * Set via {@code HttpOptions.builder().headers(Map.of("Authorization", "Bearer ...")) }. + *
  • {@link HttpOptions#timeout()} -- optional. Per-call timeout in milliseconds. A missing + * timeout defaults to 5 minutes ({@link #DEFAULT_CALL_TIMEOUT}). A timeout of {@code 0} is + * respected as the explicit caller opt-in to infinite wait. Set via {@code + * HttpOptions.builder().timeout(10_000).build()}. + *
+ * + *

Example: + * + *

{@code
+   * HttpOptions options =
+   *     HttpOptions.builder()
+   *         .baseUrl("https://example.com/v1/")
+   *         .headers(ImmutableMap.of("Authorization", "Bearer my-token"))
+   *         .timeout(30_000)
+   *         .build();
+   * ChatCompletionsHttpClient client = new ChatCompletionsHttpClient(options);
+   * }
+ * + * @param httpOptions HTTP configuration. Must not be {@code null}, and {@link + * HttpOptions#baseUrl()} must be present and parseable as an HTTP(S) URL. + * @throws IllegalArgumentException if {@code httpOptions.baseUrl()} is missing or is not a valid + * HTTP(S) URL. + */ + public ChatCompletionsHttpClient(HttpOptions httpOptions) { + Objects.requireNonNull(httpOptions, "httpOptions cannot be null"); + String baseUrl = + httpOptions + .baseUrl() + .orElseThrow(() -> new IllegalArgumentException("httpOptions.baseUrl() must be set")); + HttpUrl parsedBaseUrl = HttpUrl.parse(baseUrl); + if (parsedBaseUrl == null) { + throw new IllegalArgumentException( + "httpOptions.baseUrl() is not a valid HTTP(S) URL: " + baseUrl); + } + // Pre-build the completions URL once. HttpUrl.addPathSegment handles trailing slashes, + // percent-encoding, and existing path components on baseUrl deterministically. + this.completionsUrl = + parsedBaseUrl.newBuilder().addPathSegment("chat").addPathSegment("completions").build(); + // Defensive copy of caller-supplied headers; absent is treated as no extra headers. + this.headers = + httpOptions + .headers() + .>map(ImmutableMap::copyOf) + .orElse(ImmutableMap.of()); + + // Apply custom timeouts per instance. All internal timeouts are bounded by callTimeout. + OkHttpClient.Builder builder = SHARED_POOL_CLIENT.newBuilder(); + builder.connectTimeout(Duration.ZERO); + builder.readTimeout(Duration.ZERO); + builder.writeTimeout(Duration.ZERO); + builder.callTimeout(resolveCallTimeout(httpOptions)); + this.client = builder.build(); + } + + /** Resolves the call timeout from HttpOptions. */ + private static Duration resolveCallTimeout(HttpOptions httpOptions) { + if (httpOptions.timeout().isEmpty()) { + return DEFAULT_CALL_TIMEOUT; + } + long timeoutMs = httpOptions.timeout().get(); + // 0 is treated as no timeout (Duration.ZERO). + return timeoutMs == 0L ? Duration.ZERO : Duration.ofMillis(timeoutMs); + } + + /** + * Generates a conversational response from the chat completions endpoint based on the provided + * messages. This encapsulates building the HTTP payload, sending the request to the completions + * endpoint, and initiating the handling of complete calls. + * + * @param llmRequest The request containing the model, configuration, and sequence of messages. + * @param stream Whether to request a streaming response. + * @return A {@link Flowable} emitting the discrete (or combined) {@link LlmResponse} objects. + */ + public Flowable complete(LlmRequest llmRequest, boolean stream) { + return Flowable.defer( + () -> { + ChatCompletionsRequest dtoRequest = + ChatCompletionsRequest.fromLlmRequest(llmRequest, stream); + String jsonPayload = objectMapper.writeValueAsString(dtoRequest); + logger.trace( + "Chat Completion Request: model={}, stream={}, messagesCount={}", + dtoRequest.model, + dtoRequest.stream, + dtoRequest.messages != null ? dtoRequest.messages.size() : 0); + + Request.Builder requestBuilder = + new Request.Builder().url(completionsUrl).post(RequestBody.create(jsonPayload, JSON)); + + for (Map.Entry entry : headers.entrySet()) { + requestBuilder.addHeader(entry.getKey(), entry.getValue()); + } + // Defensively force Content-Type to JSON by replacing instead of appending. + requestBuilder.header("Content-Type", JSON.toString()); + + Request request = requestBuilder.build(); + if (stream) { + return createStreamingFlowable(request); + } else { + return createNonStreamingFlowable(request); + } + }); + } + + /** Placeholder for streaming responses. Errors with {@link UnsupportedOperationException}. */ + @SuppressWarnings("UnusedVariable") + private Flowable createStreamingFlowable(Request request) { + return Flowable.error( + new UnsupportedOperationException("Streaming is not yet implemented in this client.")); + } + + /** + * Wraps an OkHttp {@link Callback} in a reactive {@link Flowable} for single-turn, non-streaming + * responses. + */ + private Flowable createNonStreamingFlowable(Request request) { + return Flowable.create( + emitter -> { + Call call = client.newCall(request); + emitter.setCancellable(call::cancel); + call.enqueue(new NonStreamingCallback(emitter)); + }, + BackpressureStrategy.BUFFER); + } + + /** + * Handles OkHttp failure and success callbacks, pushing {@link LlmResponse} results to the given + * emitter. + */ + private static final class NonStreamingCallback implements Callback { + private final FlowableEmitter emitter; + + NonStreamingCallback(FlowableEmitter emitter) { + this.emitter = emitter; + } + + @Override + public void onFailure(Call call, IOException e) { + emitter.tryOnError(e); + } + + @Override + public void onResponse(Call call, Response response) { + try (ResponseBody body = response.body()) { + if (!response.isSuccessful()) { + String bodyStr = body != null ? body.string() : ""; + emitter.tryOnError( + new IOException("Unexpected code " + response + " - body: " + bodyStr)); + return; + } + if (body == null) { + emitter.tryOnError(new IOException("Empty response body")); + return; + } + + String jsonResponse = body.string(); + ChatCompletionsResponse.ChatCompletion completion = + objectMapper.readValue(jsonResponse, ChatCompletionsResponse.ChatCompletion.class); + emitter.onNext(completion.toLlmResponse()); + emitter.onComplete(); + } catch (Exception e) { + emitter.tryOnError(e); + } + } + } +} diff --git a/core/src/test/java/com/google/adk/models/chat/ChatCompletionsHttpClientTest.java b/core/src/test/java/com/google/adk/models/chat/ChatCompletionsHttpClientTest.java new file mode 100644 index 000000000..175ca777e --- /dev/null +++ b/core/src/test/java/com/google/adk/models/chat/ChatCompletionsHttpClientTest.java @@ -0,0 +1,477 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed 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 + * + * https://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 com.google.adk.models.chat; + +import static com.google.common.truth.Truth.assertThat; +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.adk.JsonBaseModel; +import com.google.adk.models.LlmRequest; +import com.google.adk.models.LlmResponse; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.genai.types.Content; +import com.google.genai.types.FinishReason; +import com.google.genai.types.HttpOptions; +import com.google.genai.types.Part; +import io.reactivex.rxjava3.subscribers.TestSubscriber; +import java.io.IOException; +import java.lang.reflect.Field; +import java.time.Duration; +import okhttp3.Call; +import okhttp3.Callback; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Protocol; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; +import okio.Buffer; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +@RunWith(JUnit4.class) +public final class ChatCompletionsHttpClientTest { + private static final ObjectMapper objectMapper = JsonBaseModel.getMapper(); + private static final MediaType JSON = MediaType.get("application/json"); + + /** + * Bounded wait for {@link TestSubscriber#await} so a buggy callback wiring cannot hang the test + * JVM. The mock callbacks fire synchronously in the same thread, so this value is intentionally + * short -- on a successful run the await returns in microseconds, and on a hung run we fail fast + * instead of stalling the test suite. + */ + private static final Duration AWAIT_TIMEOUT = Duration.ofMillis(500); + + @Rule public final MockitoRule mocks = MockitoJUnit.rule(); + + @Mock private OkHttpClient mockHttpClient; + @Mock private Call mockCall; + + private ChatCompletionsHttpClient client; + + @Before + public void setUp() throws Exception { + client = + new ChatCompletionsHttpClient( + HttpOptions.builder().baseUrl("https://example.com/").build()); + swapInMockHttpClient(client); + } + + /** + * Reflectively replaces the production {@link OkHttpClient} on a {@link + * ChatCompletionsHttpClient} with the test's mock so callbacks can be captured. Used by both + * setUp and tests that construct their own client (e.g. timeout tests, header tests). + */ + private void swapInMockHttpClient(ChatCompletionsHttpClient target) throws Exception { + when(mockHttpClient.newCall(any())).thenReturn(mockCall); + Field clientField = ChatCompletionsHttpClient.class.getDeclaredField("client"); + clientField.setAccessible(true); + clientField.set(target, mockHttpClient); + } + + private Response createMockResponse(String body, MediaType mediaType) { + return createMockResponse(body, mediaType, 200, "OK"); + } + + private Response createMockResponse(String body, MediaType mediaType, int code, String message) { + Response.Builder builder = + new Response.Builder() + .request(new Request.Builder().url("https://example.com/chat/completions").build()) + .protocol(Protocol.HTTP_1_1) + .code(code) + .message(message); + // OkHttp's Response.Builder rejects a null body via its Kotlin @NotNull contract; omit + // the body() call entirely to model an empty/null response body. + if (body != null) { + builder.body(ResponseBody.create(body, mediaType)); + } + return builder.build(); + } + + /** Returns a minimal {@link LlmRequest} suitable for tests that don't care about the payload. */ + private static LlmRequest minimalRequest() { + return LlmRequest.builder() + .model("gpt-4") + .contents(ImmutableList.of(Content.builder().parts(Part.fromText("hello")).build())) + .build(); + } + + @Test + public void complete_nonStreaming_sendsCorrectPayload() throws Exception { + String responseBody = + """ + { + "choices": [ + { + "message": { + "role": "assistant", + "content": "Hi" + }, + "finish_reason": "stop" + } + ] + } + """; + + Response mockResponse = createMockResponse(responseBody, JSON); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Callback.class); + doNothing().when(mockCall).enqueue(callbackCaptor.capture()); + + TestSubscriber testSubscriber = client.complete(minimalRequest(), false).test(); + + callbackCaptor.getValue().onResponse(mockCall, mockResponse); + testSubscriber.await(AWAIT_TIMEOUT.toMillis(), MILLISECONDS); + + LlmResponse response = testSubscriber.values().get(0); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(Request.class); + verify(mockHttpClient).newCall(requestCaptor.capture()); + Request capturedRequest = requestCaptor.getValue(); + assertThat(capturedRequest.url().encodedPath()).isEqualTo("/chat/completions"); + + Buffer buffer = new Buffer(); + capturedRequest.body().writeTo(buffer); + JsonNode requestBodyJson = objectMapper.readTree(buffer.readUtf8()); + assertThat(requestBodyJson.get("model").asText()).isEqualTo("gpt-4"); + assertThat(requestBodyJson.get("messages").get(0).get("role").asText()).isEqualTo("user"); + assertThat(requestBodyJson.get("messages").get(0).get("content").asText()).isEqualTo("hello"); + + LlmResponse expectedResponse = + LlmResponse.builder() + .content( + Content.builder() + .role("model") + .parts(ImmutableList.of(Part.fromText("Hi"))) + .build()) + .finishReason(new FinishReason(FinishReason.Known.STOP.toString())) + .customMetadata(ImmutableList.of()) + .build(); + + assertThat(response).isEqualTo(expectedResponse); + } + + @Test + public void complete_nonStreaming_propagateFailure() throws Exception { + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Callback.class); + doNothing().when(mockCall).enqueue(callbackCaptor.capture()); + + TestSubscriber testSubscriber = client.complete(minimalRequest(), false).test(); + + callbackCaptor.getValue().onFailure(mockCall, new IOException("Network Error")); + testSubscriber.await(AWAIT_TIMEOUT.toMillis(), MILLISECONDS); + + testSubscriber.assertError(IOException.class); + } + + // -- Header, error-propagation, and timeout coverage. ---------------------------------- + + /** + * Verifies that an HTTP error status (e.g. 500) propagates as a stream error and that the error + * message includes the response body so callers can debug. Covers the {@code + * !response.isSuccessful()} branch of the non-streaming path. The streaming counterpart lives in + * the streaming follow-up CL. + */ + @Test + public void complete_nonStreaming_propagatesHttpErrorStatus() throws Exception { + Response mockResponse = + createMockResponse("{\"error\":\"server exploded\"}", JSON, 500, "Internal Server Error"); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Callback.class); + doNothing().when(mockCall).enqueue(callbackCaptor.capture()); + + TestSubscriber testSubscriber = client.complete(minimalRequest(), false).test(); + + callbackCaptor.getValue().onResponse(mockCall, mockResponse); + testSubscriber.await(AWAIT_TIMEOUT.toMillis(), MILLISECONDS); + + testSubscriber.assertError( + e -> + e instanceof IOException + && e.getMessage().contains("Unexpected code") + && e.getMessage().contains("server exploded")); + } + + /** + * Verifies that an empty response body propagates as a stream error rather than silently emitting + * an empty value. The exact exception class depends on OkHttp's behavior: + * + *
    + *
  • If OkHttp produces a {@code null} body, our code surfaces an {@link IOException} with the + * message {@code "Empty response body"}. + *
  • If OkHttp produces an empty (non-null) body, Jackson surfaces a {@link + * com.fasterxml.jackson.databind.exc.MismatchedInputException} ("No content to map"). + *
+ * + * Both outcomes satisfy the contract: empty body must NOT silently produce a successful empty + * {@link LlmResponse}. + */ + @Test + public void complete_nonStreaming_propagatesEmptyBody() throws Exception { + Response mockResponse = createMockResponse(null, JSON); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Callback.class); + doNothing().when(mockCall).enqueue(callbackCaptor.capture()); + + TestSubscriber testSubscriber = client.complete(minimalRequest(), false).test(); + + callbackCaptor.getValue().onResponse(mockCall, mockResponse); + testSubscriber.await(AWAIT_TIMEOUT.toMillis(), MILLISECONDS); + + testSubscriber.assertNoValues(); + testSubscriber.assertError(Throwable.class); + } + + /** + * Verifies that caller-supplied headers reach the wire on the captured {@link Request}. This is + * the most common production failure mode (missing or wrong Authorization header), so it gets its + * own test rather than being implicit in other tests. + */ + @Test + public void complete_sendsCustomHeaders() throws Exception { + ChatCompletionsHttpClient clientWithHeaders = + new ChatCompletionsHttpClient( + HttpOptions.builder() + .baseUrl("https://example.com/") + .headers(ImmutableMap.of("Authorization", "Bearer test-token", "X-Custom", "value")) + .build()); + swapInMockHttpClient(clientWithHeaders); + + String responseBody = + """ + {"choices":[{"message":{"role":"assistant","content":"Hi"},"finish_reason":"stop"}]} + """; + Response mockResponse = createMockResponse(responseBody, JSON); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Callback.class); + doNothing().when(mockCall).enqueue(callbackCaptor.capture()); + + TestSubscriber testSubscriber = + clientWithHeaders.complete(minimalRequest(), false).test(); + + callbackCaptor.getValue().onResponse(mockCall, mockResponse); + testSubscriber.await(AWAIT_TIMEOUT.toMillis(), MILLISECONDS); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(Request.class); + verify(mockHttpClient).newCall(requestCaptor.capture()); + Request capturedRequest = requestCaptor.getValue(); + assertThat(capturedRequest.header("Authorization")).isEqualTo("Bearer test-token"); + assertThat(capturedRequest.header("X-Custom")).isEqualTo("value"); + // Content-Type is forced to application/json regardless of caller input. + assertThat(capturedRequest.header("Content-Type")).contains("application/json"); + } + + /** + * Verifies that even when a caller passes a conflicting {@code Content-Type} header, the client + * overrides it with {@code application/json} so the upstream API does not reject the request as a + * malformed payload. + */ + @Test + public void complete_overridesCallerContentType() throws Exception { + ChatCompletionsHttpClient clientWithBadHeader = + new ChatCompletionsHttpClient( + HttpOptions.builder() + .baseUrl("https://example.com/") + .headers(ImmutableMap.of("Content-Type", "text/plain")) + .build()); + swapInMockHttpClient(clientWithBadHeader); + + String responseBody = + """ + {"choices":[{"message":{"role":"assistant","content":"Hi"},"finish_reason":"stop"}]} + """; + Response mockResponse = createMockResponse(responseBody, JSON); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Callback.class); + doNothing().when(mockCall).enqueue(callbackCaptor.capture()); + + TestSubscriber testSubscriber = + clientWithBadHeader.complete(minimalRequest(), false).test(); + + callbackCaptor.getValue().onResponse(mockCall, mockResponse); + testSubscriber.await(AWAIT_TIMEOUT.toMillis(), MILLISECONDS); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(Request.class); + verify(mockHttpClient).newCall(requestCaptor.capture()); + Request capturedRequest = requestCaptor.getValue(); + // Should be exactly one Content-Type header, not two. + assertThat(capturedRequest.headers("Content-Type")).hasSize(1); + assertThat(capturedRequest.header("Content-Type")).contains("application/json"); + } + + /** + * Verifies that a {@code baseUrl} without a trailing slash still produces the correct {@code + * /chat/completions} path. {@link okhttp3.HttpUrl#newBuilder()} normalizes path segments + * regardless of the trailing-slash state of the base URL. + */ + @Test + public void complete_handlesBaseUrlWithoutTrailingSlash() throws Exception { + ChatCompletionsHttpClient clientNoSlash = + new ChatCompletionsHttpClient(HttpOptions.builder().baseUrl("https://example.com").build()); + swapInMockHttpClient(clientNoSlash); + + String responseBody = + """ + {"choices":[{"message":{"role":"assistant","content":"Hi"},"finish_reason":"stop"}]} + """; + Response mockResponse = createMockResponse(responseBody, JSON); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Callback.class); + doNothing().when(mockCall).enqueue(callbackCaptor.capture()); + + TestSubscriber testSubscriber = + clientNoSlash.complete(minimalRequest(), false).test(); + + callbackCaptor.getValue().onResponse(mockCall, mockResponse); + testSubscriber.await(AWAIT_TIMEOUT.toMillis(), MILLISECONDS); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(Request.class); + verify(mockHttpClient).newCall(requestCaptor.capture()); + assertThat(requestCaptor.getValue().url().encodedPath()).isEqualTo("/chat/completions"); + } + + /** + * Verifies that omitting {@code headers} on the supplied {@link HttpOptions} is treated as no + * extra headers, not as an NPE. + */ + @Test + public void constructor_missingHeaders_isTreatedAsEmpty() throws Exception { + ChatCompletionsHttpClient clientWithoutHeaders = + new ChatCompletionsHttpClient( + HttpOptions.builder().baseUrl("https://example.com/").build()); + swapInMockHttpClient(clientWithoutHeaders); + + String responseBody = + """ + {"choices":[{"message":{"role":"assistant","content":"Hi"},"finish_reason":"stop"}]} + """; + Response mockResponse = createMockResponse(responseBody, JSON); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Callback.class); + doNothing().when(mockCall).enqueue(callbackCaptor.capture()); + + TestSubscriber testSubscriber = + clientWithoutHeaders.complete(minimalRequest(), false).test(); + + callbackCaptor.getValue().onResponse(mockCall, mockResponse); + testSubscriber.await(AWAIT_TIMEOUT.toMillis(), MILLISECONDS); + + testSubscriber.assertNoErrors(); + testSubscriber.assertValueCount(1); + } + + /** Verifies that a {@code null} {@link HttpOptions} is rejected at construction time. */ + @Test + public void constructor_nullHttpOptions_throws() { + assertThrows(NullPointerException.class, () -> new ChatCompletionsHttpClient(null)); + } + + /** + * Verifies that an {@link HttpOptions} without a {@code baseUrl} is rejected at construction time + * as bad configuration. {@link IllegalArgumentException} (not NPE) is the conventional signal for + * missing required configuration. + */ + @Test + public void constructor_missingBaseUrl_throws() { + HttpOptions noBaseUrl = HttpOptions.builder().build(); + assertThrows(IllegalArgumentException.class, () -> new ChatCompletionsHttpClient(noBaseUrl)); + } + + /** + * Verifies that an {@link HttpOptions} with a malformed (non-HTTP(S)) {@code baseUrl} is rejected + * at construction time, rather than failing later at the first {@code complete()} call with a + * confusing NPE from {@link okhttp3.HttpUrl#parse}. + */ + @Test + public void constructor_malformedBaseUrl_throws() { + HttpOptions malformed = HttpOptions.builder().baseUrl("not a url").build(); + assertThrows(IllegalArgumentException.class, () -> new ChatCompletionsHttpClient(malformed)); + } + + // -- Tri-state timeout policy. ---------------------------------------------------------- + + /** + * Verifies that when {@code httpOptions} omits {@code timeout()}, the client applies the 5-minute + * default call timeout to prevent indefinite hangs in callers that did not explicitly configure a + * timeout. + */ + @Test + public void constructor_missingTimeout_appliesDefaultFiveMinuteTimeout() { + ChatCompletionsHttpClient defaultClient = + new ChatCompletionsHttpClient( + HttpOptions.builder().baseUrl("https://example.com/").build()); + + OkHttpClient internal = readInternalClient(defaultClient); + assertThat(internal.callTimeoutMillis()) + .isEqualTo((int) Duration.ofMinutes(5).toMillis()); // 300_000 + } + + /** + * Verifies that when the caller explicitly sets {@code httpOptions.timeout() == 0}, the client + * respects this as the explicit opt-in to infinite hang. This is the migration path for + * long-running streams or batch jobs that need no timeout. + */ + @Test + public void constructor_zeroTimeout_respectsInfiniteHang() { + HttpOptions zeroTimeout = + HttpOptions.builder().baseUrl("https://example.com/").timeout(0).build(); + ChatCompletionsHttpClient infiniteClient = new ChatCompletionsHttpClient(zeroTimeout); + + OkHttpClient internal = readInternalClient(infiniteClient); + assertThat(internal.callTimeoutMillis()).isEqualTo(0); // OkHttp: 0 = no timeout + } + + /** + * Verifies that when the caller sets a positive timeout, that value (in milliseconds) is used as + * the call timeout. + */ + @Test + public void constructor_explicitTimeout_appliesIt() { + HttpOptions tenSeconds = + HttpOptions.builder().baseUrl("https://example.com/").timeout(10_000).build(); + ChatCompletionsHttpClient timedClient = new ChatCompletionsHttpClient(tenSeconds); + + OkHttpClient internal = readInternalClient(timedClient); + assertThat(internal.callTimeoutMillis()).isEqualTo(10_000); + } + + /** Reflectively reads the internal {@link OkHttpClient} to inspect the resolved timeout. */ + private static OkHttpClient readInternalClient(ChatCompletionsHttpClient target) { + try { + Field clientField = ChatCompletionsHttpClient.class.getDeclaredField("client"); + clientField.setAccessible(true); + return (OkHttpClient) clientField.get(target); + } catch (ReflectiveOperationException e) { + throw new LinkageError("Failed to read internal client", e); + } + } +} From 44feab9180931620fff17d7098335703adb09789 Mon Sep 17 00:00:00 2001 From: adk-java-releases-bot Date: Thu, 14 May 2026 01:06:45 +0200 Subject: [PATCH 4/6] chore(main): release 1.3.0 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 17 +++++++++++++++++ README.md | 4 ++-- a2a/pom.xml | 2 +- contrib/firestore-session-service/pom.xml | 2 +- contrib/langchain4j/pom.xml | 2 +- contrib/planners/pom.xml | 2 +- contrib/samples/a2a_basic/pom.xml | 2 +- contrib/samples/a2a_server/pom.xml | 2 +- contrib/samples/configagent/pom.xml | 2 +- contrib/samples/helloworld/pom.xml | 2 +- contrib/samples/mcpfilesystem/pom.xml | 2 +- contrib/samples/pom.xml | 2 +- contrib/spring-ai/pom.xml | 2 +- core/pom.xml | 2 +- core/src/main/java/com/google/adk/Version.java | 2 +- dev/pom.xml | 2 +- maven_plugin/examples/custom_tools/pom.xml | 2 +- maven_plugin/examples/simple-agent/pom.xml | 2 +- maven_plugin/pom.xml | 2 +- pom.xml | 2 +- tutorials/city-time-weather/pom.xml | 2 +- tutorials/live-audio-single-agent/pom.xml | 2 +- 23 files changed, 40 insertions(+), 23 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 09a252282..6a787c5c9 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.2.0" + ".": "1.3.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index c9ae0d827..2d258953d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [1.3.0](https://github.com/google/adk-java/compare/v1.2.0...v1.3.0) (2026-05-13) + + +### Features + +* Add ChatCompletionsHTTPClient and support for non-streaming requests ([9529c1a](https://github.com/google/adk-java/commit/9529c1aeecb324e1c00c6bd105df2a0e9f67ed26)) +* Add conversion from LlmRequest to ChatCompletionsRequest ([d37f6ee](https://github.com/google/adk-java/commit/d37f6ee6d8ec036154593b734f1a3b080847cfea)) +* Add SkillSource interface and implementations for loading skills ([509c4aa](https://github.com/google/adk-java/commit/509c4aa75fdc752c2758a1761cbd8946075b310c)) +* Add support for refusal content using "[[REFUSAL]]:" prefix ([e9184c9](https://github.com/google/adk-java/commit/e9184c9846d97f65907667aa2a6bbac1f65fed64)) +* Refactor BigQueryAgentAnalyticsPlugin for async in preparation for GCS offloading ([d837ef0](https://github.com/google/adk-java/commit/d837ef0164cedd284af6caee84911569109ab7e3)) + + +### Bug Fixes + +* Account for nulls in EventActions and State ([582cf7c](https://github.com/google/adk-java/commit/582cf7c2b6534afaf5edfa501391191478d8d8ea)) +* upgrade Mockito and JaCoCo for Java 25 compatibility ([8574fc5](https://github.com/google/adk-java/commit/8574fc5bb6ac7edae99306b06c0a610f7da60048)) + ## [1.2.0](https://github.com/google/adk-java/compare/v1.1.0...v1.2.0) (2026-04-24) diff --git a/README.md b/README.md index 107a6967b..9613078dd 100644 --- a/README.md +++ b/README.md @@ -50,13 +50,13 @@ If you're using Maven, add the following to your dependencies: com.google.adk google-adk - 1.2.0 + 1.3.0 com.google.adk google-adk-dev - 1.2.0 + 1.3.0 ``` diff --git a/a2a/pom.xml b/a2a/pom.xml index 3e0b049d6..70e24e023 100644 --- a/a2a/pom.xml +++ b/a2a/pom.xml @@ -5,7 +5,7 @@ com.google.adk google-adk-parent - 1.2.1-SNAPSHOT + 1.3.0 google-adk-a2a diff --git a/contrib/firestore-session-service/pom.xml b/contrib/firestore-session-service/pom.xml index 121877444..a801efc93 100644 --- a/contrib/firestore-session-service/pom.xml +++ b/contrib/firestore-session-service/pom.xml @@ -20,7 +20,7 @@ com.google.adk google-adk-parent - 1.2.1-SNAPSHOT + 1.3.0 ../../pom.xml diff --git a/contrib/langchain4j/pom.xml b/contrib/langchain4j/pom.xml index b7e4cb56f..9c998452c 100644 --- a/contrib/langchain4j/pom.xml +++ b/contrib/langchain4j/pom.xml @@ -20,7 +20,7 @@ com.google.adk google-adk-parent - 1.2.1-SNAPSHOT + 1.3.0 ../../pom.xml diff --git a/contrib/planners/pom.xml b/contrib/planners/pom.xml index 1f9afa17a..86cb0f43c 100644 --- a/contrib/planners/pom.xml +++ b/contrib/planners/pom.xml @@ -20,7 +20,7 @@ com.google.adk google-adk-parent - 1.2.1-SNAPSHOT + 1.3.0 ../../pom.xml diff --git a/contrib/samples/a2a_basic/pom.xml b/contrib/samples/a2a_basic/pom.xml index 1e7af90ae..e511b9145 100644 --- a/contrib/samples/a2a_basic/pom.xml +++ b/contrib/samples/a2a_basic/pom.xml @@ -5,7 +5,7 @@ com.google.adk google-adk-samples - 1.2.1-SNAPSHOT + 1.3.0 .. diff --git a/contrib/samples/a2a_server/pom.xml b/contrib/samples/a2a_server/pom.xml index b1414eff4..6cc4deb4c 100644 --- a/contrib/samples/a2a_server/pom.xml +++ b/contrib/samples/a2a_server/pom.xml @@ -5,7 +5,7 @@ com.google.adk google-adk-samples - 1.2.1-SNAPSHOT + 1.3.0 .. diff --git a/contrib/samples/configagent/pom.xml b/contrib/samples/configagent/pom.xml index 097323363..463b82379 100644 --- a/contrib/samples/configagent/pom.xml +++ b/contrib/samples/configagent/pom.xml @@ -5,7 +5,7 @@ com.google.adk google-adk-samples - 1.2.1-SNAPSHOT + 1.3.0 .. diff --git a/contrib/samples/helloworld/pom.xml b/contrib/samples/helloworld/pom.xml index 4e6ad4892..eabbd547f 100644 --- a/contrib/samples/helloworld/pom.xml +++ b/contrib/samples/helloworld/pom.xml @@ -20,7 +20,7 @@ com.google.adk google-adk-samples - 1.2.1-SNAPSHOT + 1.3.0 .. diff --git a/contrib/samples/mcpfilesystem/pom.xml b/contrib/samples/mcpfilesystem/pom.xml index f4ad43c84..c3d1a8904 100644 --- a/contrib/samples/mcpfilesystem/pom.xml +++ b/contrib/samples/mcpfilesystem/pom.xml @@ -20,7 +20,7 @@ com.google.adk google-adk-parent - 1.2.1-SNAPSHOT + 1.3.0 ../../.. diff --git a/contrib/samples/pom.xml b/contrib/samples/pom.xml index d9ce06aa7..a29ef41f9 100644 --- a/contrib/samples/pom.xml +++ b/contrib/samples/pom.xml @@ -5,7 +5,7 @@ com.google.adk google-adk-parent - 1.2.1-SNAPSHOT + 1.3.0 ../.. diff --git a/contrib/spring-ai/pom.xml b/contrib/spring-ai/pom.xml index 7e8c61a8a..3abaff88b 100644 --- a/contrib/spring-ai/pom.xml +++ b/contrib/spring-ai/pom.xml @@ -20,7 +20,7 @@ com.google.adk google-adk-parent - 1.2.1-SNAPSHOT + 1.3.0 ../../pom.xml diff --git a/core/pom.xml b/core/pom.xml index 53fd51883..a51b99b1f 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -20,7 +20,7 @@ com.google.adk google-adk-parent - 1.2.1-SNAPSHOT + 1.3.0 google-adk diff --git a/core/src/main/java/com/google/adk/Version.java b/core/src/main/java/com/google/adk/Version.java index 2816d6763..d440f4d9e 100644 --- a/core/src/main/java/com/google/adk/Version.java +++ b/core/src/main/java/com/google/adk/Version.java @@ -22,7 +22,7 @@ */ public final class Version { // Don't touch this, release-please should keep it up to date. - public static final String JAVA_ADK_VERSION = "1.2.0"; // x-release-please-released-version + public static final String JAVA_ADK_VERSION = "1.3.0"; // x-release-please-released-version private Version() {} } diff --git a/dev/pom.xml b/dev/pom.xml index bf89e7ca6..8b5910f35 100644 --- a/dev/pom.xml +++ b/dev/pom.xml @@ -18,7 +18,7 @@ com.google.adk google-adk-parent - 1.2.1-SNAPSHOT + 1.3.0 google-adk-dev diff --git a/maven_plugin/examples/custom_tools/pom.xml b/maven_plugin/examples/custom_tools/pom.xml index 38bc9b561..be3bb3656 100644 --- a/maven_plugin/examples/custom_tools/pom.xml +++ b/maven_plugin/examples/custom_tools/pom.xml @@ -4,7 +4,7 @@ com.example custom-tools-example - 1.2.1-SNAPSHOT + 1.3.0 jar ADK Custom Tools Example diff --git a/maven_plugin/examples/simple-agent/pom.xml b/maven_plugin/examples/simple-agent/pom.xml index c713f525d..f3d22c47d 100644 --- a/maven_plugin/examples/simple-agent/pom.xml +++ b/maven_plugin/examples/simple-agent/pom.xml @@ -4,7 +4,7 @@ com.example simple-adk-agent - 1.2.1-SNAPSHOT + 1.3.0 jar Simple ADK Agent Example diff --git a/maven_plugin/pom.xml b/maven_plugin/pom.xml index f87df835d..c6448e22f 100644 --- a/maven_plugin/pom.xml +++ b/maven_plugin/pom.xml @@ -5,7 +5,7 @@ com.google.adk google-adk-parent - 1.2.1-SNAPSHOT + 1.3.0 ../pom.xml diff --git a/pom.xml b/pom.xml index eb5acd441..2902b8b90 100644 --- a/pom.xml +++ b/pom.xml @@ -17,7 +17,7 @@ com.google.adk google-adk-parent - 1.2.1-SNAPSHOT + 1.3.0 pom Google Agent Development Kit Maven Parent POM diff --git a/tutorials/city-time-weather/pom.xml b/tutorials/city-time-weather/pom.xml index f63dc96a8..262024ba1 100644 --- a/tutorials/city-time-weather/pom.xml +++ b/tutorials/city-time-weather/pom.xml @@ -20,7 +20,7 @@ com.google.adk google-adk-parent - 1.2.1-SNAPSHOT + 1.3.0 ../../pom.xml diff --git a/tutorials/live-audio-single-agent/pom.xml b/tutorials/live-audio-single-agent/pom.xml index 3c4475b6a..bdd2b3ff4 100644 --- a/tutorials/live-audio-single-agent/pom.xml +++ b/tutorials/live-audio-single-agent/pom.xml @@ -20,7 +20,7 @@ com.google.adk google-adk-parent - 1.2.1-SNAPSHOT + 1.3.0 ../../pom.xml From 1fb905a9b8e235de2a2a1f61fc9163c44ea06b15 Mon Sep 17 00:00:00 2001 From: adk-java-releases-bot Date: Fri, 15 May 2026 14:31:14 +0200 Subject: [PATCH 5/6] chore(main): release 1.3.1-SNAPSHOT --- a2a/pom.xml | 2 +- contrib/firestore-session-service/pom.xml | 2 +- contrib/langchain4j/pom.xml | 2 +- contrib/planners/pom.xml | 2 +- contrib/samples/a2a_basic/pom.xml | 2 +- contrib/samples/a2a_server/pom.xml | 2 +- contrib/samples/configagent/pom.xml | 2 +- contrib/samples/helloworld/pom.xml | 2 +- contrib/samples/mcpfilesystem/pom.xml | 2 +- contrib/samples/pom.xml | 2 +- contrib/spring-ai/pom.xml | 2 +- core/pom.xml | 2 +- dev/pom.xml | 2 +- maven_plugin/examples/custom_tools/pom.xml | 2 +- maven_plugin/examples/simple-agent/pom.xml | 2 +- maven_plugin/pom.xml | 2 +- pom.xml | 2 +- tutorials/city-time-weather/pom.xml | 2 +- tutorials/live-audio-single-agent/pom.xml | 2 +- 19 files changed, 19 insertions(+), 19 deletions(-) diff --git a/a2a/pom.xml b/a2a/pom.xml index 70e24e023..85eb3e0fa 100644 --- a/a2a/pom.xml +++ b/a2a/pom.xml @@ -5,7 +5,7 @@ com.google.adk google-adk-parent - 1.3.0 + 1.3.1-SNAPSHOT google-adk-a2a diff --git a/contrib/firestore-session-service/pom.xml b/contrib/firestore-session-service/pom.xml index a801efc93..58f984a31 100644 --- a/contrib/firestore-session-service/pom.xml +++ b/contrib/firestore-session-service/pom.xml @@ -20,7 +20,7 @@ com.google.adk google-adk-parent - 1.3.0 + 1.3.1-SNAPSHOT ../../pom.xml diff --git a/contrib/langchain4j/pom.xml b/contrib/langchain4j/pom.xml index 9c998452c..94fb8fa24 100644 --- a/contrib/langchain4j/pom.xml +++ b/contrib/langchain4j/pom.xml @@ -20,7 +20,7 @@ com.google.adk google-adk-parent - 1.3.0 + 1.3.1-SNAPSHOT ../../pom.xml diff --git a/contrib/planners/pom.xml b/contrib/planners/pom.xml index 86cb0f43c..eafe4cdfb 100644 --- a/contrib/planners/pom.xml +++ b/contrib/planners/pom.xml @@ -20,7 +20,7 @@ com.google.adk google-adk-parent - 1.3.0 + 1.3.1-SNAPSHOT ../../pom.xml diff --git a/contrib/samples/a2a_basic/pom.xml b/contrib/samples/a2a_basic/pom.xml index e511b9145..737daf7eb 100644 --- a/contrib/samples/a2a_basic/pom.xml +++ b/contrib/samples/a2a_basic/pom.xml @@ -5,7 +5,7 @@ com.google.adk google-adk-samples - 1.3.0 + 1.3.1-SNAPSHOT .. diff --git a/contrib/samples/a2a_server/pom.xml b/contrib/samples/a2a_server/pom.xml index 6cc4deb4c..410c45b16 100644 --- a/contrib/samples/a2a_server/pom.xml +++ b/contrib/samples/a2a_server/pom.xml @@ -5,7 +5,7 @@ com.google.adk google-adk-samples - 1.3.0 + 1.3.1-SNAPSHOT .. diff --git a/contrib/samples/configagent/pom.xml b/contrib/samples/configagent/pom.xml index 463b82379..08207ad57 100644 --- a/contrib/samples/configagent/pom.xml +++ b/contrib/samples/configagent/pom.xml @@ -5,7 +5,7 @@ com.google.adk google-adk-samples - 1.3.0 + 1.3.1-SNAPSHOT .. diff --git a/contrib/samples/helloworld/pom.xml b/contrib/samples/helloworld/pom.xml index eabbd547f..be7dc6551 100644 --- a/contrib/samples/helloworld/pom.xml +++ b/contrib/samples/helloworld/pom.xml @@ -20,7 +20,7 @@ com.google.adk google-adk-samples - 1.3.0 + 1.3.1-SNAPSHOT .. diff --git a/contrib/samples/mcpfilesystem/pom.xml b/contrib/samples/mcpfilesystem/pom.xml index c3d1a8904..d3ee68cdc 100644 --- a/contrib/samples/mcpfilesystem/pom.xml +++ b/contrib/samples/mcpfilesystem/pom.xml @@ -20,7 +20,7 @@ com.google.adk google-adk-parent - 1.3.0 + 1.3.1-SNAPSHOT ../../.. diff --git a/contrib/samples/pom.xml b/contrib/samples/pom.xml index a29ef41f9..d5e9470e7 100644 --- a/contrib/samples/pom.xml +++ b/contrib/samples/pom.xml @@ -5,7 +5,7 @@ com.google.adk google-adk-parent - 1.3.0 + 1.3.1-SNAPSHOT ../.. diff --git a/contrib/spring-ai/pom.xml b/contrib/spring-ai/pom.xml index 3abaff88b..00abe7f27 100644 --- a/contrib/spring-ai/pom.xml +++ b/contrib/spring-ai/pom.xml @@ -20,7 +20,7 @@ com.google.adk google-adk-parent - 1.3.0 + 1.3.1-SNAPSHOT ../../pom.xml diff --git a/core/pom.xml b/core/pom.xml index a51b99b1f..f0bafe01b 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -20,7 +20,7 @@ com.google.adk google-adk-parent - 1.3.0 + 1.3.1-SNAPSHOT google-adk diff --git a/dev/pom.xml b/dev/pom.xml index 8b5910f35..998c146e2 100644 --- a/dev/pom.xml +++ b/dev/pom.xml @@ -18,7 +18,7 @@ com.google.adk google-adk-parent - 1.3.0 + 1.3.1-SNAPSHOT google-adk-dev diff --git a/maven_plugin/examples/custom_tools/pom.xml b/maven_plugin/examples/custom_tools/pom.xml index be3bb3656..58538fbea 100644 --- a/maven_plugin/examples/custom_tools/pom.xml +++ b/maven_plugin/examples/custom_tools/pom.xml @@ -4,7 +4,7 @@ com.example custom-tools-example - 1.3.0 + 1.3.1-SNAPSHOT jar ADK Custom Tools Example diff --git a/maven_plugin/examples/simple-agent/pom.xml b/maven_plugin/examples/simple-agent/pom.xml index f3d22c47d..03cfce1cb 100644 --- a/maven_plugin/examples/simple-agent/pom.xml +++ b/maven_plugin/examples/simple-agent/pom.xml @@ -4,7 +4,7 @@ com.example simple-adk-agent - 1.3.0 + 1.3.1-SNAPSHOT jar Simple ADK Agent Example diff --git a/maven_plugin/pom.xml b/maven_plugin/pom.xml index c6448e22f..500329f03 100644 --- a/maven_plugin/pom.xml +++ b/maven_plugin/pom.xml @@ -5,7 +5,7 @@ com.google.adk google-adk-parent - 1.3.0 + 1.3.1-SNAPSHOT ../pom.xml diff --git a/pom.xml b/pom.xml index 2902b8b90..665b696a0 100644 --- a/pom.xml +++ b/pom.xml @@ -17,7 +17,7 @@ com.google.adk google-adk-parent - 1.3.0 + 1.3.1-SNAPSHOT pom Google Agent Development Kit Maven Parent POM diff --git a/tutorials/city-time-weather/pom.xml b/tutorials/city-time-weather/pom.xml index 262024ba1..5a559d7a4 100644 --- a/tutorials/city-time-weather/pom.xml +++ b/tutorials/city-time-weather/pom.xml @@ -20,7 +20,7 @@ com.google.adk google-adk-parent - 1.3.0 + 1.3.1-SNAPSHOT ../../pom.xml diff --git a/tutorials/live-audio-single-agent/pom.xml b/tutorials/live-audio-single-agent/pom.xml index bdd2b3ff4..764289fcb 100644 --- a/tutorials/live-audio-single-agent/pom.xml +++ b/tutorials/live-audio-single-agent/pom.xml @@ -20,7 +20,7 @@ com.google.adk google-adk-parent - 1.3.0 + 1.3.1-SNAPSHOT ../../pom.xml From b4791ef362840e79d008221f272992532c4732cd Mon Sep 17 00:00:00 2001 From: Google Team Member Date: Tue, 19 May 2026 03:57:39 -0700 Subject: [PATCH 6/6] fix: Suppress empty-text-only chunks from streaming responses while preserving carried metadata PiperOrigin-RevId: 917743448 --- .../java/com/google/adk/models/Gemini.java | 100 ++++++++-- .../com/google/adk/models/GeminiTest.java | 182 ++++++++++++++++++ 2 files changed, 266 insertions(+), 16 deletions(-) diff --git a/core/src/main/java/com/google/adk/models/Gemini.java b/core/src/main/java/com/google/adk/models/Gemini.java index 6f145e1de..40809b85e 100644 --- a/core/src/main/java/com/google/adk/models/Gemini.java +++ b/core/src/main/java/com/google/adk/models/Gemini.java @@ -226,21 +226,7 @@ public Flowable generateContent(LlmRequest llmRequest, boolean stre () -> processRawResponses( Flowable.fromFuture(streamFuture).flatMapIterable(iterable -> iterable))) - .filter( - llmResponse -> - llmResponse - .content() - .flatMap(Content::parts) - .map( - parts -> - !parts.isEmpty() - && parts.stream() - .anyMatch( - p -> - p.functionCall().isPresent() - || p.functionResponse().isPresent() - || p.text().isPresent())) - .orElse(false)); + .filter(Gemini::shouldEmit); } else { logger.debug("Sending generateContent request to model {}", effectiveModelName); return Flowable.fromFuture( @@ -298,7 +284,28 @@ static Flowable processRawResponses(FlowableDrops chunks that carry neither semantic content (i.e. they are an empty-text-only response + * per {@link #isEmptyTextOnlyResponse}) nor any useful metadata (per {@link #hasUsefulMetadata}). + * + *

Package-private for testing. + */ + static boolean shouldEmit(LlmResponse response) { + return !isEmptyTextOnlyResponse(response) || hasUsefulMetadata(response); + } + + /** + * Returns true if {@code response} carries any non-content metadata that should be propagated + * downstream (e.g. {@code usageMetadata}, {@code finishReason}, transcriptions, grounding or + * error info). Inspects only top-level {@link LlmResponse} fields; the response's content/parts + * are intentionally not considered here. + */ + private static boolean hasUsefulMetadata(LlmResponse response) { + return response.usageMetadata().isPresent() + || response.finishReason().isPresent() + || response.errorCode().isPresent() + || response.groundingMetadata().isPresent() + || response.inputTranscription().isPresent() + || response.outputTranscription().isPresent(); + } + + /** + * Returns true if {@code response} consists of exactly one {@link Part} whose only meaningful + * payload is an empty text string (i.e. {@code parts:[{text:""}]}). Such a chunk can be safely + * dropped from the streaming aggregator because it carries no semantic content for the agent + * pipeline. A part is considered to carry semantic content if any of its non-text payloads + * ({@code functionCall}, {@code functionResponse}, {@code inlineData}, {@code executableCode}, + * {@code codeExecutionResult}, {@code fileData}, {@code thoughtSignature}, {@code videoMetadata}, + * {@code toolCall}, {@code toolResponse}) is present. + */ + private static boolean isEmptyTextOnlyResponse(LlmResponse response) { + return response + .content() + .flatMap(Content::parts) + .map( + parts -> { + if (parts.size() != 1) { + return false; + } + Part part = parts.get(0); + return part.text().map(String::isEmpty).orElse(false) + && part.functionCall().isEmpty() + && part.functionResponse().isEmpty() + && part.inlineData().isEmpty() + && part.executableCode().isEmpty() + && part.codeExecutionResult().isEmpty() + && part.fileData().isEmpty() + && part.thoughtSignature().isEmpty() + && part.videoMetadata().isEmpty() + && part.toolCall().isEmpty() + && part.toolResponse().isEmpty(); + }) + .orElse(false); + } + @Override public BaseLlmConnection connect(LlmRequest llmRequest) { if (!apiClient.vertexAI()) { diff --git a/core/src/test/java/com/google/adk/models/GeminiTest.java b/core/src/test/java/com/google/adk/models/GeminiTest.java index c230f5f68..656b5e596 100644 --- a/core/src/test/java/com/google/adk/models/GeminiTest.java +++ b/core/src/test/java/com/google/adk/models/GeminiTest.java @@ -63,6 +63,81 @@ public void processRawResponses_withTextChunks_emitsPartialResponses() { isFunctionCallResponse()); } + // Regression test for b/513501918. gemini-3.1-flash-lite emits an extra trailing chunk after a + // function call: `{parts:[{text:""}], finishReason:STOP}`. That chunk must not be propagated as + // a non-partial event because BaseLlmFlow#run would treat it as the final response and + // terminate the loop before the function response is sent back to the model. The chunk's + // metadata (e.g. `finishReason`, `usageMetadata`) is preserved by emitting it on a content-less + // partial response instead of dropping the chunk entirely. + @Test + public void + processRawResponses_functionCallThenEmptyTextWithStop_emitsFunctionCallAndMetadataOnlyPartial() { + Flowable rawResponses = + Flowable.just( + toResponse(Part.fromFunctionCall("test_function", ImmutableMap.of())), + toResponseWithText("", FinishReason.Known.STOP)); + + Flowable llmResponses = Gemini.processRawResponses(rawResponses); + + assertLlmResponses( + llmResponses, + isFunctionCallResponse(), + isContentlessPartialWithFinishReason(FinishReason.Known.STOP)); + } + + // Same as above but with `usageMetadata` on the trailing empty chunk: the metadata must survive + // on the emitted content-less partial. + @Test + public void + processRawResponses_functionCallThenEmptyTextWithUsageMetadata_preservesUsageMetadata() { + GenerateContentResponseUsageMetadata metadata = createUsageMetadata(5, 10, 15); + Flowable rawResponses = + Flowable.just( + toResponse(Part.fromFunctionCall("test_function", ImmutableMap.of())), + toResponseWithText("", FinishReason.Known.STOP, metadata)); + + Flowable llmResponses = Gemini.processRawResponses(rawResponses); + + assertLlmResponses( + llmResponses, isFunctionCallResponse(), isContentlessPartialWithUsageMetadata(metadata)); + } + + // Same as above but without a finishReason or usageMetadata: the trailing empty chunk carries no + // useful payload and must be suppressed entirely. + @Test + public void processRawResponses_functionCallThenEmptyText_doesNotEmitExtraEmptyResponse() { + Flowable rawResponses = + Flowable.just( + toResponse(Part.fromFunctionCall("test_function", ImmutableMap.of())), + toResponseWithText("")); + + Flowable llmResponses = Gemini.processRawResponses(rawResponses); + + assertLlmResponses(llmResponses, isFunctionCallResponse()); + } + + // Combined scenario: leading partial text, then a function call, then the trailing empty-text + // chunk with STOP. Accumulated text must still be flushed, the function call must still be + // emitted, and the trailing chunk must surface only its metadata on a content-less partial. + @Test + public void + processRawResponses_textThenFunctionCallThenEmptyTextWithStop_emitsTextFunctionCallAndMetadata() { + Flowable rawResponses = + Flowable.just( + toResponseWithText("Thinking..."), + toResponse(Part.fromFunctionCall("test_function", ImmutableMap.of())), + toResponseWithText("", FinishReason.Known.STOP)); + + Flowable llmResponses = Gemini.processRawResponses(rawResponses); + + assertLlmResponses( + llmResponses, + isPartialTextResponse("Thinking..."), + isFinalTextResponse("Thinking..."), + isFunctionCallResponse(), + isContentlessPartialWithFinishReason(FinishReason.Known.STOP)); + } + @Test public void processRawResponses_textAndStopReason_emitsPartialThenFinalText() { Flowable rawResponses = @@ -175,6 +250,93 @@ public void processRawResponses_thoughtChunksAndStop_includeUsageMetadata() { isFinalThoughtResponseWithUsageMetadata("Thinking deeply", metadata2)); } + // Test cases for the shouldEmit filter applied by generateContent after processRawResponses. + // shouldEmit drops chunks that are empty-text-only AND carry no useful metadata; everything else + // is forwarded. processRawResponses normally already strips empty-text-only chunks, so shouldEmit + // is defense-in-depth, but it must still behave correctly when fed any LlmResponse directly. + + @Test + public void shouldEmit_emptyTextOnlyResponseWithNoMetadata_returnsFalse() { + LlmResponse response = + LlmResponse.builder() + .content(Content.builder().role("model").parts(Part.fromText("")).build()) + .build(); + + assertThat(Gemini.shouldEmit(response)).isFalse(); + } + + @Test + public void shouldEmit_emptyTextOnlyResponseWithFinishReason_returnsTrue() { + LlmResponse response = + LlmResponse.builder() + .content(Content.builder().role("model").parts(Part.fromText("")).build()) + .finishReason(new FinishReason(FinishReason.Known.STOP)) + .build(); + + assertThat(Gemini.shouldEmit(response)).isTrue(); + } + + @Test + public void shouldEmit_emptyTextOnlyResponseWithUsageMetadata_returnsTrue() { + LlmResponse response = + LlmResponse.builder() + .content(Content.builder().role("model").parts(Part.fromText("")).build()) + .usageMetadata(createUsageMetadata(5, 10, 15)) + .build(); + + assertThat(Gemini.shouldEmit(response)).isTrue(); + } + + @Test + public void shouldEmit_nonEmptyTextResponse_returnsTrue() { + LlmResponse response = + LlmResponse.builder() + .content(Content.builder().role("model").parts(Part.fromText("hello")).build()) + .build(); + + assertThat(Gemini.shouldEmit(response)).isTrue(); + } + + @Test + public void shouldEmit_functionCallResponse_returnsTrue() { + LlmResponse response = + LlmResponse.builder() + .content( + Content.builder() + .role("model") + .parts(Part.fromFunctionCall("test_function", ImmutableMap.of())) + .build()) + .build(); + + assertThat(Gemini.shouldEmit(response)).isTrue(); + } + + @Test + public void shouldEmit_contentlessResponse_returnsTrue() { + // A response with no content at all is not an empty-text-only response, so it should pass + // through regardless of metadata. This is the shape emitted by processRawResponses after it + // strips empty-text content while preserving metadata. + LlmResponse response = LlmResponse.builder().build(); + + assertThat(Gemini.shouldEmit(response)).isTrue(); + } + + @Test + public void shouldEmit_multiPartResponseWithEmptyTextPart_returnsTrue() { + // Only single-part empty-text responses are considered "empty-text-only". A multi-part response + // is treated as carrying semantic content and must always pass through. + LlmResponse response = + LlmResponse.builder() + .content( + Content.builder() + .role("model") + .parts(Part.fromText(""), Part.fromText("hello")) + .build()) + .build(); + + assertThat(Gemini.shouldEmit(response)).isTrue(); + } + @Test public void processRawResponses_thoughtAndTextWithStop_onlyFinalTextIncludesUsageMetadata() { GenerateContentResponseUsageMetadata metadata1 = createUsageMetadata(5, 5, 10); @@ -232,6 +394,26 @@ private static Predicate isFunctionCallResponse() { }; } + private static Predicate isContentlessPartialWithFinishReason( + FinishReason.Known expectedFinishReason) { + return response -> { + assertThat(response.partial()).hasValue(true); + assertThat(response.content()).isEmpty(); + assertThat(response.finishReason().map(fr -> fr.knownEnum())).hasValue(expectedFinishReason); + return true; + }; + } + + private static Predicate isContentlessPartialWithUsageMetadata( + GenerateContentResponseUsageMetadata expectedMetadata) { + return response -> { + assertThat(response.partial()).hasValue(true); + assertThat(response.content()).isEmpty(); + assertThat(response.usageMetadata()).hasValue(expectedMetadata); + return true; + }; + } + private static Predicate isEmptyResponse() { return response -> { assertThat(response.partial()).isEmpty();