Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 10 additions & 8 deletions .fernignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,19 @@ 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-keyterm fix documented lower down
# (keep it frozen until BOTH that fix and this one are upstreamed).
# 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).
src/main/java/com/deepgram/resources/speak/v2/websocket/V2WebSocketClient.java
src/main/java/com/deepgram/resources/listen/v2/websocket/V2WebSocketClient.java

# Multi-keyterm serialization fix: the generated streaming clients stringified a
# List<String> keyterm into a single query param (keyterm=[a, b]) instead of
# repeated params (keyterm=a&keyterm=b), which the server treats as one nonsense
# term and which degrades recognition. Patched to emit one param per term. 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 fixed in the generator.
# 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<String> 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
# 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

# Manual equals/hashCode contract fix: Fern generates equals() (all instances equal) but no
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -138,30 +139,25 @@ public CompletableFuture<Void> connect(V1ConnectOptions options) {
"endpointing", String.valueOf(options.getEndpointing().get()));
}
if (options.getExtra() != null && options.getExtra().isPresent()) {
urlBuilder.addQueryParameter(
"extra", String.valueOf(options.getExtra().get()));
// Array-valued query params (String | List<String> unions) must serialize as repeated
// params (extra=a&extra=b), not a stringified list. The generated streaming template
// uses String.valueOf(...), which mangles a List into "[a, b]"; route these through
// QueryStringMapper (arraysAsRepeats=true) so the wire format matches the REST path.
QueryStringMapper.addQueryParameter(
urlBuilder, "extra", options.getExtra().get().get(), true);
}
if (options.getInterimResults() != null && options.getInterimResults().isPresent()) {
urlBuilder.addQueryParameter(
"interim_results",
String.valueOf(options.getInterimResults().get()));
}
if (options.getKeyterm() != null && options.getKeyterm().isPresent()) {
// keyterm is a String | List<String> union. Emit one query param per term
// (keyterm=a&keyterm=b) rather than stringifying the whole list into a single
// param, which the server would treat as one nonsense term.
Object keytermValue = options.getKeyterm().get().get();
if (keytermValue instanceof Iterable) {
for (Object term : (Iterable<?>) keytermValue) {
urlBuilder.addQueryParameter("keyterm", String.valueOf(term));
}
} else {
urlBuilder.addQueryParameter("keyterm", String.valueOf(keytermValue));
}
QueryStringMapper.addQueryParameter(
urlBuilder, "keyterm", options.getKeyterm().get().get(), true);
}
if (options.getKeywords() != null && options.getKeywords().isPresent()) {
urlBuilder.addQueryParameter(
"keywords", String.valueOf(options.getKeywords().get()));
QueryStringMapper.addQueryParameter(
urlBuilder, "keywords", options.getKeywords().get().get(), true);
}
if (options.getLanguage() != null && options.getLanguage().isPresent()) {
urlBuilder.addQueryParameter(
Expand Down Expand Up @@ -194,23 +190,24 @@ public CompletableFuture<Void> connect(V1ConnectOptions options) {
"redact", String.valueOf(options.getRedact().get()));
}
if (options.getReplace() != null && options.getReplace().isPresent()) {
urlBuilder.addQueryParameter(
"replace", String.valueOf(options.getReplace().get()));
QueryStringMapper.addQueryParameter(
urlBuilder, "replace", options.getReplace().get().get(), true);
}
if (options.getSampleRate() != null && options.getSampleRate().isPresent()) {
urlBuilder.addQueryParameter(
"sample_rate", String.valueOf(options.getSampleRate().get()));
}
if (options.getSearch() != null && options.getSearch().isPresent()) {
urlBuilder.addQueryParameter(
"search", String.valueOf(options.getSearch().get()));
QueryStringMapper.addQueryParameter(
urlBuilder, "search", options.getSearch().get().get(), true);
}
if (options.getSmartFormat() != null && options.getSmartFormat().isPresent()) {
urlBuilder.addQueryParameter(
"smart_format", String.valueOf(options.getSmartFormat().get()));
}
if (options.getTag() != null && options.getTag().isPresent()) {
urlBuilder.addQueryParameter("tag", String.valueOf(options.getTag().get()));
QueryStringMapper.addQueryParameter(
urlBuilder, "tag", options.getTag().get().get(), true);
}
if (options.getUtteranceEndMs() != null && options.getUtteranceEndMs().isPresent()) {
urlBuilder.addQueryParameter(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -125,21 +126,16 @@ public CompletableFuture<Void> connect(V2ConnectOptions options) {
"eot_timeout_ms", String.valueOf(options.getEotTimeoutMs().get()));
}
if (options.getKeyterm() != null && options.getKeyterm().isPresent()) {
// keyterm is a String | List<String> union. Emit one query param per term
// (keyterm=a&keyterm=b) rather than stringifying the whole list into a single
// param, which the server would treat as one nonsense term.
Object keytermValue = options.getKeyterm().get().get();
if (keytermValue instanceof Iterable) {
for (Object term : (Iterable<?>) keytermValue) {
urlBuilder.addQueryParameter("keyterm", String.valueOf(term));
}
} else {
urlBuilder.addQueryParameter("keyterm", String.valueOf(keytermValue));
}
// Array-valued query params (String | List<String> unions) must serialize as repeated
// params (keyterm=a&keyterm=b), not a stringified list. The generated streaming template
// uses String.valueOf(...), which mangles a List into "[a, b]"; route these through
// QueryStringMapper (arraysAsRepeats=true) so the wire format matches the REST path.
QueryStringMapper.addQueryParameter(
urlBuilder, "keyterm", options.getKeyterm().get().get(), true);
}
if (options.getLanguageHint() != null && options.getLanguageHint().isPresent()) {
urlBuilder.addQueryParameter(
"language_hint", String.valueOf(options.getLanguageHint().get()));
QueryStringMapper.addQueryParameter(
urlBuilder, "language_hint", options.getLanguageHint().get().get(), true);
}
if (options.getProfanityFilter() != null && options.getProfanityFilter().isPresent()) {
urlBuilder.addQueryParameter(
Expand All @@ -155,7 +151,8 @@ public CompletableFuture<Void> 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()));
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);
Expand Down
79 changes: 76 additions & 3 deletions src/test/java/com/deepgram/ListenV1ConnectWireTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,13 @@

import com.deepgram.core.Environment;
import com.deepgram.resources.listen.v1.websocket.V1ConnectOptions;
import com.deepgram.types.ListenV1Extra;
import com.deepgram.types.ListenV1Keyterm;
import com.deepgram.types.ListenV1Keywords;
import com.deepgram.types.ListenV1Model;
import com.deepgram.types.ListenV1Replace;
import com.deepgram.types.ListenV1Search;
import com.deepgram.types.ListenV1Tag;
import java.util.List;
import java.util.concurrent.TimeUnit;
import okhttp3.HttpUrl;
Expand All @@ -20,12 +25,14 @@
import org.junit.jupiter.api.Test;

/**
* Hand-written connect-handshake wire coverage for the {@code keyterm} query param on
* Hand-written connect-handshake wire coverage for the array-valued query params on
* {@code listen().v1().v1WebSocket().connect(...)} (GET /v1/listen upgrade).
*
* <p>Guards that a multi-value keyterm serializes as repeated params ({@code keyterm=a&keyterm=b})
* <p>Guards that every {@code String | List<String>} query param (keyterm, keywords, replace,
* search, tag, extra) serializes a multi-value list as repeated params ({@code keyterm=a&keyterm=b})
* rather than a single stringified list ({@code keyterm=[a, b]}), which the server would treat as
* one nonsense term. Frozen via {@code src/test/} in .fernignore.
* one nonsense term, while a scalar string stays a single param. Frozen via {@code src/test/} in
* .fernignore.
*/
class ListenV1ConnectWireTest {
private MockWebServer server;
Expand Down Expand Up @@ -76,4 +83,70 @@ void keytermListSentAsRepeatedParams() throws Exception {

assertThat(url.queryParameterValues("keyterm")).containsExactly("a", "b");
}

@Test
@DisplayName("a single string keyterm is sent as one param")
void keytermStringSentAsOneParam() throws Exception {
HttpUrl url = connectAndCaptureUrl(V1ConnectOptions.builder()
.model(ListenV1Model.NOVA3)
.keyterm(ListenV1Keyterm.of("a"))
.build());

assertThat(url.queryParameterValues("keyterm")).containsExactly("a");
}

@Test
@DisplayName("multiple keywords are sent as repeated params")
void keywordsListSentAsRepeatedParams() throws Exception {
HttpUrl url = connectAndCaptureUrl(V1ConnectOptions.builder()
.model(ListenV1Model.NOVA3)
.keywords(ListenV1Keywords.of(List.of("a:2", "b")))
.build());

assertThat(url.queryParameterValues("keywords")).containsExactly("a:2", "b");
}

@Test
@DisplayName("multiple replace terms are sent as repeated params")
void replaceListSentAsRepeatedParams() throws Exception {
HttpUrl url = connectAndCaptureUrl(V1ConnectOptions.builder()
.model(ListenV1Model.NOVA3)
.replace(ListenV1Replace.of(List.of("a:b", "c:d")))
.build());

assertThat(url.queryParameterValues("replace")).containsExactly("a:b", "c:d");
}

@Test
@DisplayName("multiple search terms are sent as repeated params")
void searchListSentAsRepeatedParams() throws Exception {
HttpUrl url = connectAndCaptureUrl(V1ConnectOptions.builder()
.model(ListenV1Model.NOVA3)
.search(ListenV1Search.of(List.of("a", "b")))
.build());

assertThat(url.queryParameterValues("search")).containsExactly("a", "b");
}

@Test
@DisplayName("multiple tags are sent as repeated params")
void tagListSentAsRepeatedParams() throws Exception {
HttpUrl url = connectAndCaptureUrl(V1ConnectOptions.builder()
.model(ListenV1Model.NOVA3)
.tag(ListenV1Tag.of(List.of("a", "b")))
.build());

assertThat(url.queryParameterValues("tag")).containsExactly("a", "b");
}

@Test
@DisplayName("multiple extra values are sent as repeated params")
void extraListSentAsRepeatedParams() throws Exception {
HttpUrl url = connectAndCaptureUrl(V1ConnectOptions.builder()
.model(ListenV1Model.NOVA3)
.extra(ListenV1Extra.of(List.of("a:1", "b:2")))
.build());

assertThat(url.queryParameterValues("extra")).containsExactly("a:1", "b:2");
}
}
38 changes: 37 additions & 1 deletion src/test/java/com/deepgram/ListenV2ConnectWireTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
import com.deepgram.core.Environment;
import com.deepgram.resources.listen.v2.websocket.V2ConnectOptions;
import com.deepgram.types.ListenV2Keyterm;
import com.deepgram.types.ListenV2LanguageHint;
import com.deepgram.types.ListenV2Model;
import com.deepgram.types.ListenV2Numerals;
import com.deepgram.types.ListenV2Tag;
import java.util.List;
import java.util.concurrent.TimeUnit;
import okhttp3.HttpUrl;
Expand All @@ -22,7 +24,8 @@

/**
* Hand-written connect-handshake wire coverage for Flux STT connect query params ({@code numerals}
* and {@code keyterm}) on {@code listen().v2().v2WebSocket().connect(...)} (GET /v2/listen upgrade).
* plus the array-valued {@code keyterm}, {@code tag}, and {@code language_hint}) on
* {@code listen().v2().v2WebSocket().connect(...)} (GET /v2/listen upgrade).
*
* <p>The Fern generator did not emit a wire test for the /v2/listen handshake, so this fills the
* gap for the 2026-07-20 regen's new {@code numerals} option: it pins that {@code numerals=true}
Expand Down Expand Up @@ -112,4 +115,37 @@ void keytermStringSentAsOneParam() throws Exception {

assertThat(url.queryParameterValues("keyterm")).containsExactly("a");
}

@Test
@DisplayName("multiple tags are sent as repeated params")
void tagListSentAsRepeatedParams() throws Exception {
HttpUrl url = connectAndCaptureUrl(V2ConnectOptions.builder()
.model(ListenV2Model.FLUX_GENERAL_EN)
.tag(ListenV2Tag.of(List.of("a", "b")))
.build());

assertThat(url.queryParameterValues("tag")).containsExactly("a", "b");
}

@Test
@DisplayName("multiple language hints are sent as repeated params")
void languageHintListSentAsRepeatedParams() throws Exception {
HttpUrl url = connectAndCaptureUrl(V2ConnectOptions.builder()
.model(ListenV2Model.FLUX_GENERAL_EN)
.languageHint(ListenV2LanguageHint.of(List.of("en", "es")))
.build());

assertThat(url.queryParameterValues("language_hint")).containsExactly("en", "es");
}

@Test
@DisplayName("a single string language hint is sent as one param")
void languageHintStringSentAsOneParam() throws Exception {
HttpUrl url = connectAndCaptureUrl(V2ConnectOptions.builder()
.model(ListenV2Model.FLUX_GENERAL_EN)
.languageHint(ListenV2LanguageHint.of("en"))
.build());

assertThat(url.queryParameterValues("language_hint")).containsExactly("en");
}
}
Loading