From 82d3b4b0cf7c358a2e8ab29b7f0e50a67394bca7 Mon Sep 17 00:00:00 2001 From: Greg Holmes Date: Fri, 24 Jul 2026 10:49:37 +0100 Subject: [PATCH] fix: emit repeated tag query params for multi-value speak v2 streaming Follow-up to #82. The TTS speak v2 streaming client mangles a multi-value tag the same way the listen clients did before #82: it serializes the tag union with String.valueOf, so a multi-value list collapses into a single stringified param instead of repeated params. Same class of bug, same root cause -- the generated WebSocket template stringifies array query params instead of using the shared array-aware serializer the REST path uses. Route tag through QueryStringMapper with repeats enabled, matching the listen fix and the REST path. tag is the only array-valued query param on speak v2. Scalar strings are unchanged; an empty list now omits the param. The speak v2 client is already frozen in .fernignore; comments updated so both the dispatcher patch and this multi-value fix are documented against it. The durable fix belongs upstream in the Fern generator WebSocket template, so listen and speak get cured together. --- .fernignore | 14 +-- .../speak/v2/websocket/V2WebSocketClient.java | 8 +- .../com/deepgram/SpeakV2ConnectWireTest.java | 89 +++++++++++++++++++ 3 files changed, 103 insertions(+), 8 deletions(-) create mode 100644 src/test/java/com/deepgram/SpeakV2ConnectWireTest.java diff --git a/.fernignore b/.fernignore index 8a41c38..be7ceed 100644 --- a/.fernignore +++ b/.fernignore @@ -29,18 +29,18 @@ src/main/java/com/deepgram/core/ReconnectingWebSocketListener.java # server control frames look fatal to deployed clients. Patched to a no-op (the raw # frame is still delivered via onMessage(String)); mirrors the JS/Python SDKs. Re-apply # after regen. Unfreeze once the generator stops treating unknown frames as errors. -# NOTE: the listen v2 client below also carries the multi-value query param fix documented lower -# down (keep it frozen until BOTH that fix and this one are upstreamed). +# NOTE: both the speak v2 and listen v2 clients below also carry the multi-value query param fix +# documented lower down (keep them frozen until BOTH that fix and this one are upstreamed). src/main/java/com/deepgram/resources/speak/v2/websocket/V2WebSocketClient.java src/main/java/com/deepgram/resources/listen/v2/websocket/V2WebSocketClient.java # Multi-value query param serialization fix: the generated streaming clients serialize every -# array-valued query param (keyterm, keywords, replace, search, tag, extra) with -# String.valueOf(union.get()), which stringifies a List into a single param -# (keyterm=[a, b]) instead of repeated params (keyterm=a&keyterm=b) — the server treats the +# array-valued query param (listen: keyterm, keywords, replace, search, tag, extra, language_hint; +# speak: tag) with String.valueOf(union.get()), which stringifies a List into a single +# param (keyterm=[a, b]) instead of repeated params (keyterm=a&keyterm=b) — the server treats the # former as one nonsense term. Patched to route these through QueryStringMapper(arraysAsRepeats= -# true), matching the REST path. The v2 listen client is already frozen above; freeze the v1 -# listen client here too so the fix survives regen. Re-apply after regen; unfreeze once the +# true), matching the REST path. The v2 listen and speak clients are already frozen above; freeze +# the v1 listen client here too so the fix survives regen. Re-apply after regen; unfreeze once the # generator serializes streaming array query params as repeats (tracked as an upstream Fern request). src/main/java/com/deepgram/resources/listen/v1/websocket/V1WebSocketClient.java diff --git a/src/main/java/com/deepgram/resources/speak/v2/websocket/V2WebSocketClient.java b/src/main/java/com/deepgram/resources/speak/v2/websocket/V2WebSocketClient.java index 1ce8ea5..2e55a46 100644 --- a/src/main/java/com/deepgram/resources/speak/v2/websocket/V2WebSocketClient.java +++ b/src/main/java/com/deepgram/resources/speak/v2/websocket/V2WebSocketClient.java @@ -6,6 +6,7 @@ import com.deepgram.core.ClientOptions; import com.deepgram.core.DisconnectReason; import com.deepgram.core.ObjectMappers; +import com.deepgram.core.QueryStringMapper; import com.deepgram.core.ReconnectingWebSocketListener; import com.deepgram.core.RequestOptions; import com.deepgram.core.WebSocketReadyState; @@ -124,7 +125,12 @@ public CompletableFuture connect(V2ConnectOptions options) { "mip_opt_out", String.valueOf(options.getMipOptOut().get())); } if (options.getTag() != null && options.getTag().isPresent()) { - urlBuilder.addQueryParameter("tag", String.valueOf(options.getTag().get())); + // Array-valued query params (String | List unions) must serialize as repeated + // params (tag=a&tag=b), not a stringified list. The generated streaming template uses + // String.valueOf(...), which mangles a List into "[a, b]"; route through + // QueryStringMapper (arraysAsRepeats=true) so the wire format matches the REST path. + QueryStringMapper.addQueryParameter( + urlBuilder, "tag", options.getTag().get().get(), true); } Request.Builder requestBuilder = new Request.Builder().url(urlBuilder.build()); clientOptions.headers((RequestOptions) null).forEach(requestBuilder::addHeader); diff --git a/src/test/java/com/deepgram/SpeakV2ConnectWireTest.java b/src/test/java/com/deepgram/SpeakV2ConnectWireTest.java new file mode 100644 index 0000000..d61f520 --- /dev/null +++ b/src/test/java/com/deepgram/SpeakV2ConnectWireTest.java @@ -0,0 +1,89 @@ +package com.deepgram; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.deepgram.core.Environment; +import com.deepgram.resources.speak.v2.websocket.V2ConnectOptions; +import com.deepgram.types.SpeakV2Tag; +import java.util.List; +import java.util.concurrent.TimeUnit; +import okhttp3.HttpUrl; +import okhttp3.WebSocket; +import okhttp3.WebSocketListener; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Hand-written connect-handshake wire coverage for the array-valued {@code tag} query param on + * {@code speak().v2().v2WebSocket().connect(...)} (GET /v2/speak upgrade). + * + *

Guards that a multi-value {@code tag} ({@code String | List} union) serializes as + * repeated params ({@code tag=a&tag=b}) rather than a single stringified list ({@code tag=[a, b]}). + * Mirrors the listen v1/v2 connect wire tests. Frozen via {@code src/test/} in .fernignore. + */ +class SpeakV2ConnectWireTest { + private MockWebServer server; + private DeepgramClient client; + + @BeforeEach + void setUp() throws Exception { + server = new MockWebServer(); + server.start(); + String base = server.url("/").toString().replaceAll("/$", ""); + Environment env = Environment.custom() + .base(base) + .production(base) + .agent(base) + .agentRest(base) + .build(); + client = DeepgramClient.builder().apiKey("test").environment(env).build(); + } + + @AfterEach + void tearDown() throws Exception { + server.shutdown(); + } + + private HttpUrl connectAndCaptureUrl(V2ConnectOptions options) throws Exception { + server.enqueue(new MockResponse().withWebSocketUpgrade(new WebSocketListener() { + @Override + public void onOpen(WebSocket webSocket, okhttp3.Response response) { + webSocket.close(1000, null); + } + })); + client.speak().v2().v2WebSocket().connect(options); + RecordedRequest request = server.takeRequest(5, TimeUnit.SECONDS); + assertThat(request).as("handshake request was sent").isNotNull(); + HttpUrl url = HttpUrl.parse(server.url("/").scheme() + "://" + request.getHeader("Host") + request.getPath()); + assertThat(url).as("parsed handshake URL").isNotNull(); + assertThat(url.encodedPath()).isEqualTo("/v2/speak"); + return url; + } + + @Test + @DisplayName("multiple tags are sent as repeated params, not a stringified list") + void tagListSentAsRepeatedParams() throws Exception { + HttpUrl url = connectAndCaptureUrl(V2ConnectOptions.builder() + .model("aura-2-thalia-en") + .tag(SpeakV2Tag.of(List.of("a", "b"))) + .build()); + + assertThat(url.queryParameterValues("tag")).containsExactly("a", "b"); + } + + @Test + @DisplayName("a single string tag is sent as one param") + void tagStringSentAsOneParam() throws Exception { + HttpUrl url = connectAndCaptureUrl(V2ConnectOptions.builder() + .model("aura-2-thalia-en") + .tag(SpeakV2Tag.of("a")) + .build()); + + assertThat(url.queryParameterValues("tag")).containsExactly("a"); + } +}